Last active
May 28, 2025 06:38
-
-
Save kururu-abdo/b5019604980ff52aafb813182146d86a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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