Skip to content

Instantly share code, notes, and snippets.

@kururu-abdo
Last active May 28, 2025 06:38
Show Gist options
  • Save kururu-abdo/b5019604980ff52aafb813182146d86a to your computer and use it in GitHub Desktop.
Save kururu-abdo/b5019604980ff52aafb813182146d86a to your computer and use it in GitHub Desktop.
// pubspec.yaml
// Add these dependencies:
// dependencies:
// flutter:
// sdk: flutter
// dio: ^5.4.0 # Or the latest version
// provider: ^6.0.5 # Or the latest version
// json_annotation: ^4.8.1
// cached_network_image: ^3.3.1
// shared_preferences: ^2.2.2
// dev_dependencies:
// flutter_test:
// sdk: flutter
// flutter_lints: ^3.0.1
// json_serializable: ^6.7.1
// build_runner: ^2.4.6
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'core/odoo_api_client.dart';
import 'providers/auth_provider.dart';
import 'providers/product_provider.dart';
import 'providers/cart_provider.dart';
import 'providers/order_provider.dart';
import 'screens/login_screen.dart';
import 'screens/home_screen.dart';
import 'utils/app_constants.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
final odooApiClient = OdooApiClient();
// Load session ID if available
final savedSessionId = prefs.getString(AppConstants.sessionIdKey);
if (savedSessionId != null && savedSessionId.isNotEmpty) {
odooApiClient.setSessionId(savedSessionId);
}
runApp(
MultiProvider(
providers: [
Provider<OdooApiClient>(create: (_) => odooApiClient),
ChangeNotifierProvider(create: (context) => AuthProvider(context.read<OdooApiClient>(), prefs)),
ChangeNotifierProvider(create: (context) => ProductProvider(context.read<OdooApiClient>())),
ChangeNotifierProvider(create: (context) => CartProvider(context.read<OdooApiClient>())),
ChangeNotifierProvider(create: (context) => OrderProvider(context.read<OdooApiClient>())),
],
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Odoo E-commerce',
theme: ThemeData(
primarySwatch: Colors.blueGrey,
visualDensity: VisualDensity.adaptivePlatformDensity,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
centerTitle: true,
),
cardTheme: CardTheme(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.grey[200],
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
home: Consumer<AuthProvider>(
builder: (context, auth, _) {
if (auth.isAuthenticated) {
return const HomeScreen();
} else {
return const LoginScreen();
}
},
),
);
}
}
// lib/core/odoo_api_client.dart
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/app_constants.dart';
class OdooApiClient {
static final OdooApiClient _instance = OdooApiClient._internal();
late Dio _dio;
String? _sessionId;
SharedPreferences? _prefs;
factory OdooApiClient() {
return _instance;
}
OdooApiClient._internal() {
_dio = Dio(BaseOptions(
baseUrl: AppConstants.odooUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
contentType: Headers.jsonContentType,
));
_initInterceptors();
}
// Initialize SharedPreferences asynchronously
Future<void> _initPrefs() async {
_prefs ??= await SharedPreferences.getInstance();
}
// Set session ID and save it to SharedPreferences
Future<void> setSessionId(String? sessionId) async {
await _initPrefs();
_sessionId = sessionId;
if (sessionId != null) {
await _prefs!.setString(AppConstants.sessionIdKey, sessionId);
} else {
await _prefs!.remove(AppConstants.sessionIdKey);
}
}
String? get sessionId => _sessionId;
void _initInterceptors() {
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
if (_sessionId != null) {
options.headers['Cookie'] = 'session_id=$_sessionId';
}
options.headers['Content-Type'] = 'application/json';
return handler.next(options);
},
onResponse: (response, handler) {
// Extract session_id from Set-Cookie header if present
if (response.headers.map['set-cookie'] != null) {
final cookies = response.headers.map['set-cookie']!;
for (var cookie in cookies) {
if (cookie.contains('session_id=')) {
final newSessionId = cookie.split(';')[0].split('=')[1];
if (_sessionId != newSessionId) {
setSessionId(newSessionId); // Update and save new session ID
}
break;
}
}
}
return handler.next(response);
},
onError: (DioException e, handler) {
print('Dio Error: ${e.response?.statusCode} - ${e.message}');
if (e.response?.statusCode == 401) {
// Unauthorized - potentially session expired. Clear session.
setSessionId(null);
// Optionally, navigate to login screen or show a message
}
return handler.next(e);
},
));
}
// Generic JSON-RPC call method
Future<Response> callOdooRpc({
required String path,
required String method,
required List<dynamic> params,
String service = 'object', // 'common' for login, 'object' for ORM
}) async {
final requestBody = {
'jsonrpc': '2.0',
'method': 'call',
'params': {
'service': service,
'method': method,
'args': params,
'kwargs': {}, // Odoo expects kwargs even if empty
},
'id': DateTime.now().millisecondsSinceEpoch, // Unique request ID
};
try {
final response = await _dio.post(path, data: requestBody);
if (response.data['error'] != null) {
throw DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: response.data['error']['data']['message'] ?? 'Odoo API Error',
);
}
return response;
} on DioException {
rethrow; // Re-throw Dio specific errors
} catch (e) {
throw Exception('Failed to connect to Odoo: $e');
}
}
// Specific method for execute_kw (most common ORM calls)
Future<Response> executeKw({
required String model,
required String method,
List<dynamic> args = const [],
Map<String, dynamic> kwargs = const {},
}) async {
final requestBody = {
'jsonrpc': '2.0',
'method': 'call',
'params': {
'service': 'object',
'method': 'execute_kw',
'args': [
AppConstants.odooDbName, // Database name
_sessionId != null ? 1 : 0, // Placeholder UID (Odoo will use session uid)
_sessionId, // Placeholder password (not used with session_id)
model,
method,
args,
kwargs,
],
'kwargs': {},
},
'id': DateTime.now().millisecondsSinceEpoch,
};
try {
final response = await _dio.post('/web/dataset/call_kw', data: requestBody);
if (response.data['error'] != null) {
throw DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: response.data['error']['data']['message'] ?? 'Odoo API Error',
);
}
return response;
} on DioException {
rethrow;
} catch (e) {
throw Exception('Failed to execute Odoo method: $e');
}
}
}
// lib/services/auth_service.dart
import 'package:dio/dio.dart';
import '../core/odoo_api_client.dart';
import '../utils/app_constants.dart';
class AuthService {
final OdooApiClient _apiClient;
AuthService(this._apiClient);
Future<bool> login(String username, String password) async {
try {
final response = await _apiClient.callOdooRpc(
path: '/web/session/authenticate',
method: 'authenticate',
service: 'common',
params: [AppConstants.odooDbName, username, password, {}],
);
if (response.data['result'] != null && response.data['result']['uid'] != null) {
// Session ID is automatically handled by the interceptor
print('Logged in successfully! UID: ${response.data['result']['uid']}');
return true;
} else {
print('Login failed: ${response.data['error']['message']}');
return false;
}
} on DioException catch (e) {
print('Login Dio Error: ${e.message}');
return false;
} catch (e) {
print('Login Error: $e');
return false;
}
}
Future<void> logout() async {
try {
await _apiClient.callOdooRpc(
path: '/web/session/logout',
method: 'logout',
service: 'common',
params: [],
);
await _apiClient.setSessionId(null); // Clear session from client and storage
print('Logged out successfully.');
} on DioException catch (e) {
print('Logout Dio Error: ${e.message}');
} catch (e) {
print('Logout Error: $e');
}
}
}
// lib/services/product_service.dart
import 'package:dio/dio.dart';
import '../core/odoo_api_client.dart';
import '../models/product.dart';
import '../models/category.dart';
class ProductService {
final OdooApiClient _apiClient;
ProductService(this._apiClient);
Future<List<ProductCategory>> fetchProductCategories() async {
try {
final response = await _apiClient.executeKw(
model: 'product.public.category',
method: 'search_read',
kwargs: {
'fields': ['id', 'name'],
'domain': [['parent_id', '=', false]], // Top-level categories
},
);
if (response.data['result'] != null) {
final List<dynamic> categoryData = response.data['result'];
return categoryData.map((json) => ProductCategory.fromJson(json)).toList();
}
return [];
} on DioException catch (e) {
print('Fetch Categories Dio Error: ${e.message}');
return [];
} catch (e) {
print('Fetch Categories Error: $e');
return [];
}
}
Future<List<Product>> fetchProducts({
int offset = 0,
int limit = 20,
int? categoryId,
String? searchKeyword,
}) async {
List<dynamic> domain = [];
if (categoryId != null) {
domain.add(['public_categ_ids', 'in', [categoryId]]);
}
if (searchKeyword != null && searchKeyword.isNotEmpty) {
domain.add(['name', 'ilike', searchKeyword]);
}
domain.add(['website_published', '=', true]); // Only published products
try {
final response = await _apiClient.executeKw(
model: 'product.template',
method: 'search_read',
kwargs: {
'domain': domain,
'fields': ['id', 'name', 'list_price', 'image_1920', 'public_categ_ids'],
'offset': offset,
'limit': limit,
'order': 'name asc',
},
);
if (response.data['result'] != null) {
final List<dynamic> productData = response.data['result'];
return productData.map((json) => Product.fromJson(json)).toList();
}
return [];
} on DioException catch (e) {
print('Fetch Products Dio Error: ${e.message}');
return [];
} catch (e) {
print('Fetch Products Error: $e');
return [];
}
}
Future<Product?> fetchProductDetails(int productId) async {
try {
final response = await _apiClient.executeKw(
model: 'product.template',
method: 'read',
args: [
[productId]
],
kwargs: {
'fields': [
'id', 'name', 'list_price', 'image_1920', 'description_sale',
'public_categ_ids', 'product_variant_ids', // Get product variants
'attribute_line_ids', // Attributes (color, size)
],
},
);
if (response.data['result'] != null && response.data['result'].isNotEmpty) {
return Product.fromJson(response.data['result'][0]);
}
return null;
} on DioException catch (e) {
print('Fetch Product Details Dio Error: ${e.message}');
return null;
} catch (e) {
print('Fetch Product Details Error: $e');
return null;
}
}
}
// lib/services/cart_service.dart
import 'package:dio/dio.dart';
import '../core/odoo_api_client.dart';
import '../models/cart_item.dart';
import '../models/product.dart';
class CartService {
final OdooApiClient _apiClient;
int? _currentSaleOrderId; // The ID of the active sale.order (shopping cart)
CartService(this._apiClient);
int? get currentSaleOrderId => _currentSaleOrderId;
Future<void> _ensureCartExists() async {
if (_currentSaleOrderId == null) {
try {
// Create a new sale order for the current user
// In a real app, you'd get the current user's partner_id after login.
// For simplicity, we'll use a placeholder or assume a default partner.
// You might need to fetch the partner_id from res.users after login.
final createResponse = await _apiClient.executeKw(
model: 'sale.order',
method: 'create',
args: [
{
// 'partner_id': _apiClient.currentUserId, // If you have user ID
'partner_id': 3, // Placeholder: Replace with actual customer partner_id
}
],
);
_currentSaleOrderId = createResponse.data['result'];
print('New Sale Order (Cart) created: $_currentSaleOrderId');
} on DioException catch (e) {
print('Error creating cart: ${e.message}');
rethrow;
}
}
}
Future<void> addOrUpdateCartItem(Product product, int quantity) async {
await _ensureCartExists(); // Ensure a cart exists for the current session
try {
// Fetch current cart lines to check if product already exists
final cartLinesResponse = await _apiClient.executeKw(
model: 'sale.order.line',
method: 'search_read',
kwargs: {
'domain': [
['order_id', '=', _currentSaleOrderId],
['product_id', '=', product.id]
],
'fields': ['id', 'product_uom_qty'],
},
);
final List<dynamic> existingLines = cartLinesResponse.data['result'];
if (existingLines.isNotEmpty) {
// Product already in cart, update quantity
final lineId = existingLines[0]['id'];
final currentQty = existingLines[0]['product_uom_qty'];
final newQty = currentQty + quantity;
await _apiClient.executeKw(
model: 'sale.order.line',
method: 'write',
args: [
[lineId],
{'product_uom_qty': newQty}
],
);
print('Updated quantity for product ${product.name} to $newQty');
} else {
// Product not in cart, add new line
await _apiClient.executeKw(
model: 'sale.order',
method: 'write',
args: [
[_currentSaleOrderId],
{
'order_line': [
[
0,
0,
{
'product_id': product.id,
'product_uom_qty': quantity,
}
]
]
}
],
);
print('Added product ${product.name} to cart.');
}
} on DioException catch (e) {
print('Error adding/updating cart item: ${e.message}');
rethrow;
} catch (e) {
print('Unexpected error in addOrUpdateCartItem: $e');
rethrow;
}
}
Future<void> updateCartItemQuantity(int lineId, int newQuantity) async {
if (_currentSaleOrderId == null) return; // No active cart
try {
if (newQuantity <= 0) {
await _apiClient.executeKw(
model: 'sale.order.line',
method: 'unlink',
args: [
[lineId]
],
);
print('Removed cart item line: $lineId');
} else {
await _apiClient.executeKw(
model: 'sale.order.line',
method: 'write',
args: [
[lineId],
{'product_uom_qty': newQuantity}
],
);
print('Updated cart item line $lineId to quantity $newQuantity');
}
} on DioException catch (e) {
print('Error updating cart item quantity: ${e.message}');
rethrow;
} catch (e) {
print('Unexpected error in updateCartItemQuantity: $e');
rethrow;
}
}
Future<void> removeCartItem(int lineId) async {
if (_currentSaleOrderId == null) return; // No active cart
try {
await _apiClient.executeKw(
model: 'sale.order.line',
method: 'unlink',
args: [
[lineId]
],
);
print('Removed cart item line: $lineId');
} on DioException catch (e) {
print('Error removing cart item: ${e.message}');
rethrow;
} catch (e) {
print('Unexpected error in removeCartItem: $e');
rethrow;
}
}
Future<List<CartItem>> fetchCartItems() async {
if (_currentSaleOrderId == null) {
return []; // No active cart, return empty list
}
try {
final response = await _apiClient.executeKw(
model: 'sale.order',
method: 'read',
args: [
[_currentSaleOrderId]
],
kwargs: {
'fields': ['order_line', 'amount_total'],
},
);
if (response.data['result'] != null && response.data['result'].isNotEmpty) {
final orderData = response.data['result'][0];
final List<dynamic> orderLineIds = orderData['order_line'];
if (orderLineIds.isEmpty) return [];
final orderLinesResponse = await _apiClient.executeKw(
model: 'sale.order.line',
method: 'read',
args: [orderLineIds],
kwargs: {
'fields': [
'id', 'product_id', 'name', 'product_uom_qty', 'price_unit',
'price_subtotal', 'product_template_id', // To get image from product.template
],
},
);
if (orderLinesResponse.data['result'] != null) {
final List<dynamic> cartItemData = orderLinesResponse.data['result'];
return cartItemData.map((json) => CartItem.fromJson(json)).toList();
}
}
return [];
} on DioException catch (e) {
print('Fetch Cart Items Dio Error: ${e.message}');
return [];
} catch (e) {
print('Fetch Cart Items Error: $e');
return [];
}
}
// Clear the current cart ID (e.g., after checkout or logout)
void clearCartId() {
_currentSaleOrderId = null;
}
}
// lib/services/order_service.dart
import 'package:dio/dio.dart';
import '../core/odoo_api_client.dart';
import '../models/order.dart';
class OrderService {
final OdooApiClient _apiClient;
OrderService(this._apiClient);
Future<bool> placeOrder(int saleOrderId) async {
try {
// Confirm the sale order
final response = await _apiClient.executeKw(
model: 'sale.order',
method: 'action_confirm', // Odoo's method to confirm a sale order
args: [
[saleOrderId]
],
);
if (response.data['result'] != null) {
print('Order $saleOrderId placed successfully!');
return true;
}
return false;
} on DioException catch (e) {
print('Place Order Dio Error: ${e.message}');
return false;
} catch (e) {
print('Place Order Error: $e');
return false;
}
}
Future<List<Order>> fetchOrderHistory() async {
try {
// In a real app, you'd filter by the current user's partner_id
final response = await _apiClient.executeKw(
model: 'sale.order',
method: 'search_read',
kwargs: {
'domain': [
['state', 'in', ['sale', 'done']], // Confirmed or done orders
['partner_id', '=', 3], // Placeholder: Filter by current user's partner_id
],
'fields': ['id', 'name', 'date_order', 'amount_total', 'state'],
'order': 'date_order desc',
},
);
if (response.data['result'] != null) {
final List<dynamic> orderData = response.data['result'];
return orderData.map((json) => Order.fromJson(json)).toList();
}
return [];
} on DioException catch (e) {
print('Fetch Order History Dio Error: ${e.message}');
return [];
} catch (e) {
print('Fetch Order History Error: $e');
return [];
}
}
Future<Order?> fetchOrderDetails(int orderId) async {
try {
final response = await _apiClient.executeKw(
model: 'sale.order',
method: 'read',
args: [
[orderId]
],
kwargs: {
'fields': [
'id', 'name', 'date_order', 'amount_total', 'state', 'order_line',
'partner_id', 'currency_id', // Add more relevant fields
],
},
);
if (response.data['result'] != null && response.data['result'].isNotEmpty) {
final orderData = response.data['result'][0];
final List<dynamic> orderLineIds = orderData['order_line'];
List<OrderLine> lines = [];
if (orderLineIds.isNotEmpty) {
final orderLinesResponse = await _apiClient.executeKw(
model: 'sale.order.line',
method: 'read',
args: [orderLineIds],
kwargs: {
'fields': [
'id', 'product_id', 'name', 'product_uom_qty', 'price_unit',
'price_subtotal', 'product_template_id', // To get image from product.template
],
},
);
if (orderLinesResponse.data['result'] != null) {
lines = (orderLinesResponse.data['result'] as List)
.map((json) => OrderLine.fromJson(json))
.toList();
}
}
return Order.fromJson(orderData, orderLines: lines);
}
return null;
} on DioException catch (e) {
print('Fetch Order Details Dio Error: ${e.message}');
return null;
} catch (e) {
print('Fetch Order Details Error: $e');
return null;
}
}
}
// lib/models/product.dart
import 'package:json_annotation/json_annotation.dart';
part 'product.g.dart'; // Generated file
@JsonSerializable()
class Product {
final int id;
final String name;
@JsonKey(name: 'list_price')
final double listPrice;
@JsonKey(name: 'image_1920')
final String? imageUrl; // Base64 encoded image or URL
@JsonKey(name: 'description_sale')
final String? description;
@JsonKey(name: 'public_categ_ids')
final List<dynamic>? categoryIds; // List of [ID, Name] tuples from Odoo
@JsonKey(name: 'product_variant_ids')
final List<dynamic>? productVariantIds; // List of product.product IDs
@JsonKey(name: 'attribute_line_ids')
final List<dynamic>? attributeLineIds; // List of product.attribute.line IDs
Product({
required this.id,
required this.name,
required this.listPrice,
this.imageUrl,
this.description,
this.categoryIds,
this.productVariantIds,
this.attributeLineIds,
});
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
Map<String, dynamic> toJson() => _$ProductToJson(this);
// Helper to get the first variant ID if available, otherwise use product template ID
int get defaultVariantId => productVariantIds != null && productVariantIds!.isNotEmpty
? (productVariantIds![0] is List ? productVariantIds![0][0] : productVariantIds![0] as int)
: id; // Fallback to template ID if no variants
}
// lib/models/category.dart
import 'package:json_annotation/json_annotation.dart';
part 'category.g.dart'; // Generated file
@JsonSerializable()
class ProductCategory {
final int id;
final String name;
ProductCategory({
required this.id,
required this.name,
});
factory ProductCategory.fromJson(Map<String, dynamic> json) => _$ProductCategoryFromJson(json);
Map<String, dynamic> toJson() => _$ProductCategoryToJson(this);
}
// lib/models/cart_item.dart
import 'package:json_annotation/json_annotation.dart';
part 'cart_item.g.dart'; // Generated file
@JsonSerializable()
class CartItem {
final int id; // sale.order.line ID
@JsonKey(name: 'product_id')
final List<dynamic> productId; // [ID, Name] tuple of product.product
final String name; // Product name
@JsonKey(name: 'product_uom_qty')
final double quantity;
@JsonKey(name: 'price_unit')
final double unitPrice;
@JsonKey(name: 'price_subtotal')
final double subtotal;
@JsonKey(name: 'product_template_id')
final List<dynamic> productTemplateId; // [ID, Name] tuple of product.template
CartItem({
required this.id,
required this.productId,
required this.name,
required this.quantity,
required this.unitPrice,
required this.subtotal,
required this.productTemplateId,
});
factory CartItem.fromJson(Map<String, dynamic> json) => _$CartItemFromJson(json);
Map<String, dynamic> toJson() => _$CartItemToJson(this);
// Helper to get product template ID for image
int get productTemplateImageId => productTemplateId[0] as int;
}
// lib/models/order.dart
import 'package:json_annotation/json_annotation.dart';
part 'order.g.dart'; // Generated file
@JsonSerializable()
class Order {
final int id;
final String name; // Order reference (e.g., SO001)
@JsonKey(name: 'date_order')
final String orderDate; // Date string
@JsonKey(name: 'amount_total')
final double amountTotal;
final String state; // 'draft', 'sent', 'sale', 'done', 'cancel'
@JsonKey(ignore: true) // Order lines are fetched separately
final List<OrderLine>? orderLines;
Order({
required this.id,
required this.name,
required this.orderDate,
required this.amountTotal,
required this.state,
this.orderLines,
});
factory Order.fromJson(Map<String, dynamic> json, {List<OrderLine>? orderLines}) {
return _$OrderFromJson(json).._orderLines = orderLines;
}
Map<String, dynamic> toJson() => _$OrderToJson(this);
// Private field to hold order lines if passed during construction
List<OrderLine>? _orderLines;
List<OrderLine>? get getOrderLines => _orderLines;
}
@JsonSerializable()
class OrderLine {
final int id; // sale.order.line ID
@JsonKey(name: 'product_id')
final List<dynamic> productId; // [ID, Name] tuple of product.product
final String name; // Product name
@JsonKey(name: 'product_uom_qty')
final double quantity;
@JsonKey(name: 'price_unit')
final double unitPrice;
@JsonKey(name: 'price_subtotal')
final double subtotal;
@JsonKey(name: 'product_template_id')
final List<dynamic> productTemplateId; // [ID, Name] tuple of product.template
OrderLine({
required this.id,
required this.productId,
required this.name,
required this.quantity,
required this.unitPrice,
required this.subtotal,
required this.productTemplateId,
});
factory OrderLine.fromJson(Map<String, dynamic> json) => _$OrderLineFromJson(json);
Map<String, dynamic> toJson() => _$OrderLineToJson(this);
int get productTemplateImageId => productTemplateId[0] as int;
}
// lib/providers/auth_provider.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/odoo_api_client.dart';
import '../services/auth_service.dart';
import '../utils/app_constants.dart';
class AuthProvider with ChangeNotifier {
final AuthService _authService;
final SharedPreferences _prefs;
bool _isAuthenticated = false;
String? _currentUsername; // Store current username for display
AuthProvider(OdooApiClient apiClient, this._prefs) : _authService = AuthService(apiClient) {
_isAuthenticated = apiClient.sessionId != null;
_currentUsername = _prefs.getString(AppConstants.usernameKey);
}
bool get isAuthenticated => _isAuthenticated;
String? get currentUsername => _currentUsername;
Future<bool> login(String username, String password) async {
final success = await _authService.login(username, password);
if (success) {
_isAuthenticated = true;
_currentUsername = username;
await _prefs.setString(AppConstants.usernameKey, username);
notifyListeners();
}
return success;
}
Future<void> logout() async {
await _authService.logout();
_isAuthenticated = false;
_currentUsername = null;
await _prefs.remove(AppConstants.usernameKey);
notifyListeners();
}
}
// lib/providers/product_provider.dart
import 'package:flutter/material.dart';
import '../models/product.dart';
import '../models/category.dart';
import '../services/product_service.dart';
class ProductProvider with ChangeNotifier {
final ProductService _productService;
List<ProductCategory> _categories = [];
List<Product> _products = [];
bool _isLoadingCategories = false;
bool _isLoadingProducts = false;
String? _errorMessage;
int? _selectedCategoryId;
ProductProvider(productService) : _productService = productService;
List<ProductCategory> get categories => _categories;
List<Product> get products => _products;
bool get isLoadingCategories => _isLoadingCategories;
bool get isLoadingProducts => _isLoadingProducts;
String? get errorMessage => _errorMessage;
int? get selectedCategoryId => _selectedCategoryId;
Future<void> fetchCategories() async {
_isLoadingCategories = true;
_errorMessage = null;
notifyListeners();
try {
_categories = await _productService.fetchProductCategories();
} catch (e) {
_errorMessage = 'Failed to load categories: $e';
print(_errorMessage);
} finally {
_isLoadingCategories = false;
notifyListeners();
}
}
Future<void> fetchProducts({int? categoryId, String? searchKeyword}) async {
_isLoadingProducts = true;
_errorMessage = null;
_selectedCategoryId = categoryId;
notifyListeners();
try {
_products = await _productService.fetchProducts(
categoryId: categoryId,
searchKeyword: searchKeyword,
);
} catch (e) {
_errorMessage = 'Failed to load products: $e';
print(_errorMessage);
} finally {
_isLoadingProducts = false;
notifyListeners();
}
}
void clearProducts() {
_products = [];
_selectedCategoryId = null;
notifyListeners();
}
}
// lib/providers/cart_provider.dart
import 'package:flutter/material.dart';
import '../models/cart_item.dart';
import '../models/product.dart';
import '../services/cart_service.dart';
class CartProvider with ChangeNotifier {
final CartService _cartService;
List<CartItem> _cartItems = [];
bool _isLoadingCart = false;
String? _errorMessage;
CartProvider(cartService) : _cartService = cartService;
List<CartItem> get cartItems => _cartItems;
bool get isLoadingCart => _isLoadingCart;
String? get errorMessage => _errorMessage;
double get totalAmount => _cartItems.fold(0.0, (sum, item) => sum + item.subtotal);
int get totalItems => _cartItems.fold(0, (sum, item) => sum + item.quantity.toInt());
int? get currentSaleOrderId => _cartService.currentSaleOrderId;
Future<void> fetchCartItems() async {
_isLoadingCart = true;
_errorMessage = null;
notifyListeners();
try {
_cartItems = await _cartService.fetchCartItems();
} catch (e) {
_errorMessage = 'Failed to load cart: $e';
print(_errorMessage);
} finally {
_isLoadingCart = false;
notifyListeners();
}
}
Future<void> addProductToCart(Product product, int quantity) async {
_isLoadingCart = true;
_errorMessage = null;
notifyListeners();
try {
await _cartService.addOrUpdateCartItem(product, quantity);
await fetchCartItems(); // Refresh cart after update
} catch (e) {
_errorMessage = 'Failed to add product to cart: $e';
print(_errorMessage);
} finally {
_isLoadingCart = false;
notifyListeners();
}
}
Future<void> updateCartItemQuantity(CartItem item, int newQuantity) async {
_isLoadingCart = true;
_errorMessage = null;
notifyListeners();
try {
await _cartService.updateCartItemQuantity(item.id, newQuantity);
await fetchCartItems(); // Refresh cart after update
} catch (e) {
_errorMessage = 'Failed to update cart item quantity: $e';
print(_errorMessage);
} finally {
_isLoadingCart = false;
notifyListeners();
}
}
Future<void> removeCartItem(CartItem item) async {
_isLoadingCart = true;
_errorMessage = null;
notifyListeners();
try {
await _cartService.removeCartItem(item.id);
await fetchCartItems(); // Refresh cart after update
} catch (e) {
_errorMessage = 'Failed to remove cart item: $e';
print(_errorMessage);
} finally {
_isLoadingCart = false;
notifyListeners();
}
}
void clearCart() {
_cartItems = [];
_cartService.clearCartId(); // Clear the Odoo sale order ID
notifyListeners();
}
}
// lib/providers/order_provider.dart
import 'package:flutter/material.dart';
import '../models/order.dart';
import '../services/order_service.dart';
class OrderProvider with ChangeNotifier {
final OrderService _orderService;
List<Order> _orderHistory = [];
bool _isLoadingOrders = false;
String? _errorMessage;
OrderProvider(orderService) : _orderService = orderService;
List<Order> get orderHistory => _orderHistory;
bool get isLoadingOrders => _isLoadingOrders;
String? get errorMessage => _errorMessage;
Future<bool> placeOrder(int saleOrderId) async {
_isLoadingOrders = true;
_errorMessage = null;
notifyListeners();
try {
final success = await _orderService.placeOrder(saleOrderId);
if (success) {
await fetchOrderHistory(); // Refresh order history after placing new order
}
return success;
} catch (e) {
_errorMessage = 'Failed to place order: $e';
print(_errorMessage);
return false;
} finally {
_isLoadingOrders = false;
notifyListeners();
}
}
Future<void> fetchOrderHistory() async {
_isLoadingOrders = true;
_errorMessage = null;
notifyListeners();
try {
_orderHistory = await _orderService.fetchOrderHistory();
} catch (e) {
_errorMessage = 'Failed to load order history: $e';
print(_errorMessage);
} finally {
_isLoadingOrders = false;
notifyListeners();
}
}
Future<Order?> fetchOrderDetails(int orderId) async {
_isLoadingOrders = true;
_errorMessage = null;
notifyListeners();
try {
final order = await _orderService.fetchOrderDetails(orderId);
return order;
} catch (e) {
_errorMessage = 'Failed to load order details: $e';
print(_errorMessage);
return null;
} finally {
_isLoadingOrders = false;
notifyListeners();
}
}
}
// lib/screens/login_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../widgets/loading_indicator.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
Future<void> _login() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final success = await authProvider.login(
_usernameController.text,
_passwordController.text,
);
setState(() {
_isLoading = false;
});
if (!success) {
setState(() {
_errorMessage = 'Login failed. Please check your credentials.';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login to Odoo E-commerce')),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/odoo_logo.png', // Add an Odoo logo image in your assets folder
height: 120,
),
const SizedBox(height: 40),
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
prefixIcon: Icon(Icons.person),
),
),
const SizedBox(height: 20),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
),
),
const SizedBox(height: 30),
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red, fontSize: 14),
textAlign: TextAlign.center,
),
),
_isLoading
? const LoadingIndicator()
: ElevatedButton(
onPressed: _login,
child: const Text('Login'),
),
const SizedBox(height: 20),
TextButton(
onPressed: () {
// TODO: Implement forgot password / registration
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Forgot password / Register functionality not implemented yet.')),
);
},
child: const Text('Forgot Password? / Register'),
),
],
),
),
),
);
}
}
// lib/screens/home_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
import '../providers/product_provider.dart';
import '../providers/cart_provider.dart';
import '../screens/product_list_screen.dart';
import '../screens/cart_screen.dart';
import '../screens/order_history_screen.dart';
import '../screens/profile_screen.dart';
import '../widgets/loading_indicator.dart';
import '../widgets/product_card.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
Provider.of<ProductProvider>(context, listen: false).fetchCategories();
Provider.of<ProductProvider>(context, listen: false).fetchProducts(); // Fetch initial products
Provider.of<CartProvider>(context, listen: false).fetchCartItems(); // Fetch cart on home load
});
}
@override
Widget build(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context);
final productProvider = Provider.of<ProductProvider>(context);
final cartProvider = Provider.of<CartProvider>(context);
return Scaffold(
appBar: AppBar(
title: const Text('Odoo Shop'),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
// TODO: Implement search functionality
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Search functionality not implemented yet.')),
);
},
),
Stack(
children: [
IconButton(
icon: const Icon(Icons.shopping_cart),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const CartScreen()),
);
},
),
if (cartProvider.totalItems > 0)
Positioned(
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
child: Text(
'${cartProvider.totalItems}',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
),
textAlign: TextAlign.center,
),
),
),
],
),
],
),
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: <Widget>[
UserAccountsDrawerHeader(
accountName: Text(authProvider.currentUsername ?? 'Guest'),
accountEmail: const Text(''), // Odoo doesn't directly expose email on session
currentAccountPicture: const CircleAvatar(
backgroundColor: Colors.white,
child: Icon(Icons.person, size: 50, color: Colors.blueGrey),
),
decoration: const BoxDecoration(
color: Colors.blueGrey,
),
),
ListTile(
leading: const Icon(Icons.home),
title: const Text('Home'),
onTap: () {
Navigator.pop(context); // Close the drawer
productProvider.clearProducts(); // Reset products to default
productProvider.fetchProducts();
},
),
ListTile(
leading: const Icon(Icons.category),
title: const Text('Categories'),
onTap: () {
Navigator.pop(context); // Close the drawer
showModalBottomSheet(
context: context,
builder: (context) {
return Consumer<ProductProvider>(
builder: (context, provider, child) {
if (provider.isLoadingCategories) {
return const LoadingIndicator();
}
if (provider.errorMessage != null) {
return Center(child: Text(provider.errorMessage!));
}
return ListView.builder(
itemCount: provider.categories.length,
itemBuilder: (context, index) {
final category = provider.categories[index];
return ListTile(
title: Text(category.name),
onTap: () {
Navigator.pop(context); // Close bottom sheet
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProductListScreen(
categoryId: category.id,
categoryName: category.name,
),
),
);
},
);
},
);
},
);
},
);
},
),
ListTile(
leading: const Icon(Icons.history),
title: const Text('Order History'),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const OrderHistoryScreen()),
);
},
),
ListTile(
leading: const Icon(Icons.person),
title: const Text('Profile'),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ProfileScreen()),
);
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout),
title: const Text('Logout'),
onTap: () async {
Navigator.pop(context); // Close the drawer
await authProvider.logout();
Provider.of<CartProvider>(context, listen: false).clearCart(); // Clear cart on logout
},
),
],
),
),
body: productProvider.isLoadingProducts
? const LoadingIndicator()
: productProvider.errorMessage != null
? Center(child: Text(productProvider.errorMessage!))
: productProvider.products.isEmpty
? const Center(child: Text('No products found.'))
: GridView.builder(
padding: const EdgeInsets.all(16.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
childAspectRatio: 0.7, // Adjust as needed
),
itemCount: productProvider.products.length,
itemBuilder: (context, index) {
final product = productProvider.products[index];
return ProductCard(product: product);
},
),
);
}
}
// lib/screens/product_list_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/product_provider.dart';
import '../widgets/loading_indicator.dart';
import '../widgets/product_card.dart';
class ProductListScreen extends StatefulWidget {
final int? categoryId;
final String? categoryName;
const ProductListScreen({
super.key,
this.categoryId,
this.categoryName,
});
@override
State<ProductListScreen> createState() => _ProductListScreenState();
}
class _ProductListScreenState extends State<ProductListScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
Provider.of<ProductProvider>(context, listen: false).fetchProducts(
categoryId: widget.categoryId,
);
});
}
@override
Widget build(BuildContext context) {
final productProvider = Provider.of<ProductProvider>(context);
return Scaffold(
appBar: AppBar(
title: Text(widget.categoryName ?? 'All Products'),
),
body: productProvider.isLoadingProducts
? const LoadingIndicator()
: productProvider.errorMessage != null
? Center(child: Text(productProvider.errorMessage!))
: productProvider.products.isEmpty
? const Center(child: Text('No products found in this category.'))
: GridView.builder(
padding: const EdgeInsets.all(16.0),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 16.0,
mainAxisSpacing: 16.0,
childAspectRatio: 0.7,
),
itemCount: productProvider.products.length,
itemBuilder: (context, index) {
final product = productProvider.products[index];
return ProductCard(product: product);
},
),
);
}
}
// lib/screens/product_detail_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/product.dart';
import '../providers/cart_provider.dart';
import '../services/product_service.dart';
import '../widgets/loading_indicator.dart';
import '../utils/image_utils.dart';
import '../core/odoo_api_client.dart';
class ProductDetailScreen extends StatefulWidget {
final Product product;
const ProductDetailScreen({super.key, required this.product});
@override
State<ProductDetailScreen> createState() => _ProductDetailScreenState();
}
class _ProductDetailScreenState extends State<ProductDetailScreen> {
Product? _detailedProduct;
bool _isLoadingDetails = true;
String? _errorMessage;
int _quantity = 1;
@override
void initState() {
super.initState();
_fetchProductDetails();
}
Future<void> _fetchProductDetails() async {
setState(() {
_isLoadingDetails = true;
_errorMessage = null;
});
try {
final productService = ProductService(Provider.of<OdooApiClient>(context, listen: false));
_detailedProduct = await productService.fetchProductDetails(widget.product.id);
} catch (e) {
_errorMessage = 'Failed to load product details: $e';
} finally {
setState(() {
_isLoadingDetails = false;
});
}
}
void _addToCart() async {
if (_detailedProduct == null) return;
final cartProvider = Provider.of<CartProvider>(context, listen: false);
await cartProvider.addProductToCart(_detailedProduct!, _quantity);
if (cartProvider.errorMessage == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${_quantity}x ${_detailedProduct!.name} added to cart!')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${cartProvider.errorMessage}')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.product.name),
),
body: _isLoadingDetails
? const LoadingIndicator()
: _errorMessage != null
? Center(child: Text(_errorMessage!))
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: _detailedProduct!.imageUrl != null
? CachedNetworkImage(
imageUrl: ImageUtils.getOdooImageUrl(
_detailedProduct!.imageUrl!,
_detailedProduct!.id,
),
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) =>
Image.asset('assets/placeholder_image.png'), // Placeholder image
height: 250,
fit: BoxFit.contain,
)
: Image.asset(
'assets/placeholder_image.png', // Fallback local placeholder
height: 250,
fit: BoxFit.contain,
),
),
const SizedBox(height: 20),
Text(
_detailedProduct!.name,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
Text(
'\$${_detailedProduct!.listPrice.toStringAsFixed(2)}',
style: const TextStyle(
fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green),
),
const SizedBox(height: 20),
const Text(
'Description:',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
_detailedProduct!.description ?? 'No description available.',
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 30),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.remove_circle),
onPressed: () {
setState(() {
if (_quantity > 1) _quantity--;
});
},
),
Text(
'$_quantity',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.add_circle),
onPressed: () {
setState(() {
_quantity++;
});
},
),
const SizedBox(width: 20),
Expanded(
child: ElevatedButton(
onPressed: _addToCart,
child: const Text('Add to Cart'),
),
),
],
),
],
),
),
);
}
}
// lib/screens/cart_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/cart_provider.dart';
import '../providers/order_provider.dart';
import '../widgets/loading_indicator.dart';
import '../widgets/cart_item_card.dart';
class CartScreen extends StatefulWidget {
const CartScreen({super.key});
@override
State<CartScreen> createState() => _CartScreenState();
}
class _CartScreenState extends State<CartScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
Provider.of<CartProvider>(context, listen: false).fetchCartItems();
});
}
Future<void> _checkout() async {
final cartProvider = Provider.of<CartProvider>(context, listen: false);
final orderProvider = Provider.of<OrderProvider>(context, listen: false);
if (cartProvider.cartItems.isEmpty || cartProvider.currentSaleOrderId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Your cart is empty!')),
);
return;
}
// Show a confirmation dialog
final bool? confirm = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Confirm Order'),
content: Text('Total: \$${cartProvider.totalAmount.toStringAsFixed(2)}\n\nProceed with checkout?'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Confirm'),
),
],
);
},
);
if (confirm == true) {
bool success = await orderProvider.placeOrder(cartProvider.currentSaleOrderId!);
if (success) {
cartProvider.clearCart(); // Clear cart after successful order
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Order placed successfully!')),
);
Navigator.pop(context); // Go back to previous screen (e.g., home)
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to place order: ${orderProvider.errorMessage}')),
);
}
}
}
@override
Widget build(BuildContext context) {
final cartProvider = Provider.of<CartProvider>(context);
return Scaffold(
appBar: AppBar(
title: const Text('Shopping Cart'),
),
body: cartProvider.isLoadingCart
? const LoadingIndicator()
: cartProvider.errorMessage != null
? Center(child: Text(cartProvider.errorMessage!))
: Column(
children: [
Expanded(
child: cartProvider.cartItems.isEmpty
? const Center(child: Text('Your cart is empty.'))
: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: cartProvider.cartItems.length,
itemBuilder: (context, index) {
final item = cartProvider.cartItems[index];
return CartItemCard(
item: item,
onQuantityChanged: (newQty) async {
await cartProvider.updateCartItemQuantity(item, newQty);
},
onRemove: () async {
await cartProvider.removeCartItem(item);
},
);
},
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Total:',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
Text(
'\$${cartProvider.totalAmount.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: cartProvider.cartItems.isEmpty ? null : _checkout,
child: const Text('Proceed to Checkout'),
),
),
],
),
),
],
),
);
}
}
// lib/screens/order_history_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; // For date formatting
import '../providers/order_provider.dart';
import '../widgets/loading_indicator.dart';
import '../models/order.dart'; // Import the Order model
class OrderHistoryScreen extends StatefulWidget {
const OrderHistoryScreen({super.key});
@override
State<OrderHistoryScreen> createState() => _OrderHistoryScreenState();
}
class _OrderHistoryScreenState extends State<OrderHistoryScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
Provider.of<OrderProvider>(context, listen: false).fetchOrderHistory();
});
}
@override
Widget build(BuildContext context) {
final orderProvider = Provider.of<OrderProvider>(context);
return Scaffold(
appBar: AppBar(
title: const Text('Order History'),
),
body: orderProvider.isLoadingOrders
? const LoadingIndicator()
: orderProvider.errorMessage != null
? Center(child: Text(orderProvider.errorMessage!))
: orderProvider.orderHistory.isEmpty
? const Center(child: Text('No past orders found.'))
: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: orderProvider.orderHistory.length,
itemBuilder: (context, index) {
final order = orderProvider.orderHistory[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0),
child: InkWell(
onTap: () {
_showOrderDetails(context, order.id);
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Order: ${order.name}',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Date: ${DateFormat('yyyy-MM-dd HH:mm').format(DateTime.parse(order.orderDate))}',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 8),
Text(
'Total: \$${order.amountTotal.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 16, color: Colors.green),
),
const SizedBox(height: 8),
Text(
'Status: ${order.state.toUpperCase()}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: order.state == 'sale'
? Colors.blue
: order.state == 'done'
? Colors.green
: Colors.orange,
),
),
],
),
),
),
);
},
),
);
}
Future<void> _showOrderDetails(BuildContext context, int orderId) async {
final orderProvider = Provider.of<OrderProvider>(context, listen: false);
final Order? orderDetails = await orderProvider.fetchOrderDetails(orderId);
if (orderDetails != null) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7,
minChildSize: 0.4,
maxChildSize: 0.9,
builder: (_, scrollController) {
return Container(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
height: 5,
width: 50,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(10),
),
),
),
const SizedBox(height: 16),
Text(
'Order Details: ${orderDetails.name}',
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 10),
Text(
'Date: ${DateFormat('yyyy-MM-dd HH:mm').format(DateTime.parse(orderDetails.orderDate))}',
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
Text(
'Total: \$${orderDetails.amountTotal.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.green),
),
Text(
'Status: ${orderDetails.state.toUpperCase()}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: orderDetails.state == 'sale'
? Colors.blue
: orderDetails.state == 'done'
? Colors.green
: Colors.orange,
),
),
const SizedBox(height: 20),
const Text(
'Items:',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
Expanded(
child: orderDetails.getOrderLines != null && orderDetails.getOrderLines!.isNotEmpty
? ListView.builder(
controller: scrollController,
itemCount: orderDetails.getOrderLines!.length,
itemBuilder: (context, index) {
final line = orderDetails.getOrderLines![index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
SizedBox(
width: 60,
height: 60,
child: ImageUtils.buildOdooImage(
line.productTemplateImageId),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
line.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text('Qty: ${line.quantity.toInt()} x \$${line.unitPrice.toStringAsFixed(2)}'),
Text('Subtotal: \$${line.subtotal.toStringAsFixed(2)}'),
],
),
),
],
),
),
);
},
)
: const Center(child: Text('No items in this order.')),
),
],
),
);
},
);
},
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load order details: ${orderProvider.errorMessage}')),
);
}
}
}
// lib/screens/profile_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/auth_provider.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
final authProvider = Provider.of<AuthProvider>(context);
return Scaffold(
appBar: AppBar(
title: const Text('Profile'),
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Center(
child: CircleAvatar(
radius: 60,
backgroundColor: Colors.blueGrey,
child: Icon(Icons.person, size: 80, color: Colors.white),
),
),
const SizedBox(height: 30),
Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Username:',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
Text(
authProvider.currentUsername ?? 'N/A',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
const Text(
'Status:',
style: TextStyle(fontSize: 16, color: Colors.grey),
),
Text(
authProvider.isAuthenticated ? 'Logged In' : 'Logged Out',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: authProvider.isAuthenticated ? Colors.green : Colors.red,
),
),
],
),
),
),
const SizedBox(height: 30),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () async {
await authProvider.logout();
// No need to navigate, main.dart will handle based on auth state
},
icon: const Icon(Icons.logout),
label: const Text('Logout'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red, // Red button for logout
foregroundColor: Colors.white,
),
),
),
],
),
),
);
}
}
// lib/widgets/product_card.dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../models/product.dart';
import '../screens/product_detail_screen.dart';
import '../utils/image_utils.dart';
class ProductCard extends StatelessWidget {
final Product product;
const ProductCard({super.key, required this.product});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProductDetailScreen(product: product),
),
);
},
child: Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Center(
child: product.imageUrl != null
? CachedNetworkImage(
imageUrl: ImageUtils.getOdooImageUrl(
product.imageUrl!,
product.id,
),
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) =>
Image.asset('assets/placeholder_image.png'), // Placeholder image
fit: BoxFit.contain,
)
: Image.asset(
'assets/placeholder_image.png', // Fallback local placeholder
fit: BoxFit.contain,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 4),
Text(
'\$${product.listPrice.toStringAsFixed(2)}',
style: const TextStyle(color: Colors.green, fontSize: 14),
),
],
),
),
],
),
),
);
}
}
// lib/widgets/cart_item_card.dart
import 'package:flutter/material.dart';
import '../models/cart_item.dart';
import '../utils/image_utils.dart';
class CartItemCard extends StatelessWidget {
final CartItem item;
final ValueChanged<int> onQuantityChanged;
final VoidCallback onRemove;
const CartItemCard({
super.key,
required this.item,
required this.onQuantityChanged,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 8.0),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
SizedBox(
width: 80,
height: 80,
child: ImageUtils.buildOdooImage(item.productTemplateImageId),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'\$${item.unitPrice.toStringAsFixed(2)} / item',
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 8),
Row(
children: [
IconButton(
icon: const Icon(Icons.remove_circle),
onPressed: () => onQuantityChanged(item.quantity.toInt() - 1),
),
Text(
'${item.quantity.toInt()}',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.add_circle),
onPressed: () => onQuantityChanged(item.quantity.toInt() + 1),
),
const Spacer(),
Text(
'\$${item.subtotal.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.green),
),
],
),
],
),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: onRemove,
),
],
),
),
);
}
}
// lib/widgets/loading_indicator.dart
import 'package:flutter/material.dart';
class LoadingIndicator extends StatelessWidget {
const LoadingIndicator({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.blueGrey),
),
);
}
}
// lib/utils/app_constants.dart
class AppConstants {
// IMPORTANT: Replace with your Odoo instance URL and database name
static const String odooUrl = 'http://localhost:8069'; // e.g., 'https://yourcompany.odoo.com'
static const String odooDbName = 'odoo_db'; // e.g., 'mycompany_production'
static const String sessionIdKey = 'odoo_session_id';
static const String usernameKey = 'odoo_username';
}
// lib/utils/image_utils.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:ecommerce_app/utils/app_constants.dart'; // Import AppConstants
class ImageUtils {
// Odoo can return images as base64 or serve them via URL.
// This helper tries to handle both.
// For images stored in Odoo, they are typically served at /web/image/<model>/<id>/<field_name>
static String getOdooImageUrl(String base64OrUrl, int recordId) {
// Check if it's a base64 string (starts with data:image or looks like base64)
if (base64OrUrl.startsWith('data:image') || base64OrUrl.length > 1000) {
// If it's a base64 string, we can't directly use CachedNetworkImage.
// For simplicity in this example, we'll assume a URL pattern if it's not a full base64 string.
// In a real app, you'd decode base64 to Image.memory or use a custom image provider.
// For Odoo, it's common for 'image_1920' to be a base64 string.
// However, Odoo also provides a URL endpoint for images.
// We'll construct the URL for the product template image.
return '${AppConstants.odooUrl}/web/image/product.template/$recordId/image_1920';
}
// If it's already a URL, return it as is.
return base64OrUrl;
}
// Helper to build an Image widget for Odoo images
static Widget buildOdooImage(int productTemplateId, {double? width, double? height, BoxFit? fit}) {
final imageUrl = '${AppConstants.odooUrl}/web/image/product.template/$productTemplateId/image_1920';
return CachedNetworkImage(
imageUrl: imageUrl,
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => Image.asset('assets/placeholder_image.png'), // Fallback
width: width,
height: height,
fit: fit ?? BoxFit.cover,
);
}
// If Odoo returns actual base64 strings (not just a URL in the field)
static ImageProvider<Object> decodeBase64Image(String base64String) {
if (base64String.startsWith('data:image')) {
// Remove the "data:image/jpeg;base64," prefix if present
base64String = base64String.split(',').last;
}
return MemoryImage(base64Decode(base64String));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment