refactor: implement centralized Odoo session expiration handling with automatic login redirection and unify service calls

This commit is contained in:
Suherdy Yacob 2026-06-14 16:00:40 +07:00
parent b2363b6c6b
commit 82cc2534bc
8 changed files with 78 additions and 34 deletions

View File

@ -56,6 +56,8 @@ void main() async {
runApp(OdooLoyaltyApp(homeWidget: homeWidget));
}
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
class OdooLoyaltyApp extends StatelessWidget {
final Widget homeWidget;
const OdooLoyaltyApp({super.key, required this.homeWidget});
@ -68,6 +70,7 @@ class OdooLoyaltyApp extends StatelessWidget {
return MaterialApp(
title: 'Mie Mapan Loyalty App',
debugShowCheckedModeBanner: false,
navigatorKey: navigatorKey,
theme: ThemeManager.instance.themeData,
home: homeWidget,
);

View File

@ -96,7 +96,7 @@ class CarouselDetailScreen extends StatelessWidget {
width: double.infinity,
height: 220,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _placeholder(),
errorBuilder: (_, _, _) => _placeholder(),
loadingBuilder: (ctx, child, progress) {
if (progress == null) return child;
return Container(

View File

@ -12,7 +12,8 @@ import 'forgot_password_screen.dart';
import '../theme/app_theme.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
final bool sessionExpired;
const LoginScreen({super.key, this.sessionExpired = false});
@override
State<LoginScreen> createState() => _LoginScreenState();
@ -27,6 +28,16 @@ class _LoginScreenState extends State<LoginScreen> {
void initState() {
super.initState();
_loadAppConfig();
if (widget.sessionExpired) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Your session has expired. Please sign in again.'),
backgroundColor: AppTheme.primary,
),
);
});
}
}
Future<void> _loadAppConfig() async {

View File

@ -58,10 +58,9 @@ class _MainShellState extends State<MainShell> {
Future<void> _fetchNotificationCount() async {
try {
final client = OdooService().client;
if (client == null) return;
if (OdooService().client == null) return;
final response = await client.callRPC(
final response = await OdooService().callRPC(
'/api/loyalty/fetch_notifications',
'call',
{'last_id': 0},

View File

@ -26,10 +26,9 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
Future<void> _fetchNotifications() async {
try {
final client = OdooService().client;
if (client == null) throw Exception('Not connected');
if (OdooService().client == null) throw Exception('Not connected');
final response = await client.callRPC(
final response = await OdooService().callRPC(
'/api/loyalty/fetch_notifications',
'call',
{'last_id': 0},
@ -92,7 +91,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 20),
itemCount: _notifications.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final notif = _notifications[index];
final isUnread = !_readIds.contains((notif['id'] as int? ?? 0).toString());

View File

@ -376,7 +376,7 @@ class _SignupScreenState extends State<SignupScreen> {
const SizedBox(height: 20),
DropdownButtonFormField<String>(
value: _selectedGender,
initialValue: _selectedGender,
items: _genderOptions.map((String value) {
return DropdownMenuItem<String>(
value: value,

View File

@ -1,4 +1,8 @@
import 'package:flutter/material.dart';
import 'package:odoo_rpc/odoo_rpc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../main.dart';
import '../screens/login_screen.dart';
import 'config.dart';
import 'theme_manager.dart';
@ -31,17 +35,54 @@ class OdooService {
String promoImageUrl(int promoId) =>
'${AppConfig.odooUrl}/web/image/mapan.app.promo/$promoId/image';
Future<void> _handleSessionExpired() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('odoo_session');
final state = navigatorKey.currentState;
if (state != null) {
state.pushAndRemoveUntil(
MaterialPageRoute(
builder: (_) => const LoginScreen(sessionExpired: true),
),
(route) => false,
);
}
}
Future<T> _performWithSessionCheck<T>(Future<T> Function() operation) async {
try {
return await operation();
} on OdooSessionExpiredException {
await _handleSessionExpired();
rethrow;
} catch (e) {
if (e.toString().toLowerCase().contains('session expired') ||
e.toString().toLowerCase().contains('session_expired')) {
await _handleSessionExpired();
}
rethrow;
}
}
Future<dynamic> callKw(Map<String, dynamic> params) async {
if (client == null) throw Exception("Connect to Odoo first");
return _performWithSessionCheck(() => client!.callKw(params));
}
Future<dynamic> callRPC(String path, String method, dynamic params) async {
if (client == null) throw Exception("Connect to Odoo first");
return _performWithSessionCheck(() => client!.callRPC(path, method, params));
}
Future<OdooSession> login(String db, String username, String password) async {
if (client == null) throw Exception("Connect to Odoo first");
return await client!.authenticate(db, username, password);
}
Future<List<dynamic>> getLoyaltyCards(int partnerId) async {
if (client == null) throw Exception("Connect to Odoo first");
// Only fetch cards from 'loyalty' type programs (multi-tier: Silver/Gold/Platinum).
// Excludes subscriptions, coupons, gift cards, promotions, eWallets, etc.
return await client!.callKw({
return await callKw({
'model': 'loyalty.card',
'method': 'search_read',
'args': [
@ -65,9 +106,7 @@ class OdooService {
/// Fetch subscription cards only displayed as a "My Subscriptions" list.
Future<List<dynamic>> getSubscriptionCards(int partnerId) async {
if (client == null) throw Exception("Connect to Odoo first");
return await client!.callKw({
return await callKw({
'model': 'loyalty.card',
'method': 'search_read',
'args': [
@ -91,8 +130,7 @@ class OdooService {
/// Fetch carousel slides and promo highlights for the home screen.
Future<Map<String, dynamic>> getCmsContent() async {
if (client == null) throw Exception("Connect to Odoo first");
final response = await client!.callRPC(
final response = await callRPC(
'/api/loyalty/cms_content',
'call',
{},
@ -141,8 +179,7 @@ class OdooService {
/// Fetch loyalty point history for the current user.
Future<List<dynamic>> getOrderHistory() async {
if (client == null) throw Exception("Connect to Odoo first");
final response = await client!.callRPC(
final response = await callRPC(
'/api/loyalty/order_history',
'call',
{},
@ -159,14 +196,13 @@ class OdooService {
String? phoneOrEmail,
required String type,
}) async {
if (client == null) throw Exception("Connect to Odoo first");
return await client!.callRPC(
return await callRPC(
'/api/loyalty/send_otp',
'call',
{
if (email != null) 'email': email,
if (phone != null) 'phone': phone,
if (phoneOrEmail != null) 'phone_or_email': phoneOrEmail,
'email': ?email,
'phone': ?phone,
'phone_or_email': ?phoneOrEmail,
'type': type,
},
);
@ -181,8 +217,7 @@ class OdooService {
required String password,
required String otp,
}) async {
if (client == null) throw Exception("Connect to Odoo first");
return await client!.callRPC(
return await callRPC(
'/api/loyalty/signup_member',
'call',
{
@ -204,8 +239,7 @@ class OdooService {
required String password,
required String otp,
}) async {
if (client == null) throw Exception("Connect to Odoo first");
return await client!.callRPC(
return await callRPC(
'/api/loyalty/activate_account',
'call',
{
@ -223,8 +257,7 @@ class OdooService {
required String otp,
required String password,
}) async {
if (client == null) throw Exception("Connect to Odoo first");
return await client!.callRPC(
return await callRPC(
'/api/loyalty/reset_password',
'call',
{
@ -236,8 +269,7 @@ class OdooService {
}
Future<dynamic> deleteAccount(String password) async {
if (client == null) throw Exception("Connect to Odoo first");
return await client!.callRPC(
return await callRPC(
'/api/loyalty/delete_account',
'call',
{

View File

@ -121,7 +121,7 @@ class _SlideImage extends StatelessWidget {
externalUrl,
fit: BoxFit.cover,
width: double.infinity,
errorBuilder: (_, __, ___) => _placeholder(colorScheme),
errorBuilder: (_, _, _) => _placeholder(colorScheme),
loadingBuilder: (ctx, child, progress) {
if (progress == null) return child;
return const Center(child: CircularProgressIndicator());