feat: add registration, activation, and settings workflows with centralized configuration and legal disclosures

This commit is contained in:
Suherdy Yacob 2026-06-13 17:37:30 +07:00
parent b0b75de3b6
commit 8c92887bfb
8 changed files with 1105 additions and 16 deletions

View File

@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import '../services/odoo_service.dart';
import '../services/config.dart';
import '../theme/app_theme.dart';
import '../widgets/agreement_dialog.dart';
class ActivationScreen extends StatefulWidget {
const ActivationScreen({super.key});
@override
State<ActivationScreen> createState() => _ActivationScreenState();
}
class _ActivationScreenState extends State<ActivationScreen> {
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
DateTime? _selectedDate;
bool _agreedToTerms = false;
bool _isLoading = false;
void _selectBirthDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: DateTime(2000, 1, 1),
firstDate: DateTime(1920),
lastDate: DateTime.now(),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: AppTheme.secondary, // Header background color
onPrimary: Colors.white, // Header text color
onSurface: AppTheme.onSurface, // Body text color
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppTheme.secondary, // Button text color
),
),
),
child: child!,
);
},
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
});
}
}
void _activate() async {
final phone = _phoneController.text.trim();
final password = _passwordController.text;
final confirmPassword = _confirmPasswordController.text;
if (phone.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter your phone number.')),
);
return;
}
if (_selectedDate == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select your birth date.')),
);
return;
}
if (password.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter your password.')),
);
return;
}
if (password != confirmPassword) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Passwords do not match.')),
);
return;
}
if (!_agreedToTerms) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('You must agree to the Terms & Conditions and Privacy Policy.')),
);
return;
}
setState(() => _isLoading = true);
try {
final formattedDate = "${_selectedDate!.year}-${_selectedDate!.month.toString().padLeft(2, '0')}-${_selectedDate!.day.toString().padLeft(2, '0')}";
final service = OdooService();
// Ensure connected (will use same URL as login)
service.connect(AppConfig.odooUrl);
final response = await service.activateAccount(phone, formattedDate, password);
if (response != null && response['status'] == 'success') {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(response['message'] ?? 'Account activated successfully!')),
);
Navigator.of(context).pop();
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(response?['message'] ?? 'Activation failed.')),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Activate Account'),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'First time login?',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppTheme.onSurface,
fontSize: 28,
),
),
const SizedBox(height: 8),
Text(
'Enter your phone number and registered birth date to set your password and activate your membership account.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.onSurfaceVariant,
),
),
const SizedBox(height: 36),
TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Phone Number',
hintText: 'e.g. 08123456789',
),
),
const SizedBox(height: 20),
InkWell(
onTap: _selectBirthDate,
child: Container(
color: AppTheme.surfaceContainer,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedDate == null
? 'Select Birth Date'
: 'Birth Date: ${_selectedDate!.day.toString().padLeft(2, '0')}-${_selectedDate!.month.toString().padLeft(2, '0')}-${_selectedDate!.year}',
style: TextStyle(
color: _selectedDate == null ? AppTheme.onSurfaceVariant : AppTheme.onSurface,
fontSize: 16,
),
),
const Icon(Icons.calendar_today, color: AppTheme.onSurfaceVariant),
],
),
),
),
const SizedBox(height: 20),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'New Password',
),
obscureText: true,
),
const SizedBox(height: 20),
TextField(
controller: _confirmPasswordController,
decoration: const InputDecoration(
labelText: 'Confirm Password',
),
obscureText: true,
),
const SizedBox(height: 24),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: _agreedToTerms,
activeColor: AppTheme.secondary,
onChanged: (val) {
setState(() {
_agreedToTerms = val ?? false;
});
},
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Wrap(
children: [
const Text('I agree to the ', style: TextStyle(color: AppTheme.onSurfaceVariant, fontSize: 13)),
GestureDetector(
onTap: () => AgreementDialog.show(
context,
'Terms & Conditions',
AgreementTexts.termsAndConditions
),
child: const Text(
'Terms & Conditions',
style: TextStyle(
color: AppTheme.secondary,
fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
fontSize: 13,
),
),
),
const Text(' and ', style: TextStyle(color: AppTheme.onSurfaceVariant, fontSize: 13)),
GestureDetector(
onTap: () => AgreementDialog.show(
context,
'Privacy Policy',
AgreementTexts.privacyPolicy
),
child: const Text(
'Privacy Policy',
style: TextStyle(
color: AppTheme.secondary,
fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
fontSize: 13,
),
),
),
],
),
),
),
],
),
const SizedBox(height: 36),
SizedBox(
height: 56,
child: _isLoading
? const Center(child: CircularProgressIndicator())
: ElevatedButton(
onPressed: _activate,
child: const Text(
'Activate Account',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
);
}
}

View File

@ -3,8 +3,11 @@ import 'package:flutter/material.dart';
import 'package:odoo_rpc/odoo_rpc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/odoo_service.dart';
import '../services/config.dart';
import 'loyalty_dashboard.dart';
import 'branches_screen.dart';
import 'activation_screen.dart';
import 'signup_screen.dart';
import '../theme/app_theme.dart';
class LoginScreen extends StatefulWidget {
@ -15,26 +18,23 @@ class LoginScreen extends StatefulWidget {
}
class _LoginScreenState extends State<LoginScreen> {
final _usernameController = TextEditingController();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
final String _odooUrl = 'https://erp.mapan.co.id';
final String _odooDb = 'mapangroup_o19';
void _login() async {
setState(() => _isLoading = true);
try {
final service = OdooService();
service.connect(_odooUrl);
service.connect(AppConfig.odooUrl);
final session = await service.login(
_odooDb,
_usernameController.text.trim(),
AppConfig.odooDb,
_phoneController.text.trim(),
_passwordController.text.trim(),
);
final prefs = await SharedPreferences.getInstance();
await prefs.setString('odoo_url', _odooUrl);
await prefs.setString('odoo_url', AppConfig.odooUrl);
final sessionJson = json.encode({
'id': session.id,
'user_id': session.userId,
@ -100,9 +100,11 @@ class _LoginScreenState extends State<LoginScreen> {
),
const SizedBox(height: 80),
TextField(
controller: _usernameController,
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Email / Username',
labelText: 'Phone Number',
hintText: 'e.g. 08123456789',
),
),
const SizedBox(height: 20),
@ -127,7 +129,49 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
),
const SizedBox(height: 32),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const ActivationScreen(),
),
),
style: TextButton.styleFrom(
foregroundColor: AppTheme.secondary,
padding: EdgeInsets.zero,
),
child: const Text(
'Activate Account',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
TextButton(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SignupScreen()),
),
style: TextButton.styleFrom(
foregroundColor: AppTheme.secondary,
padding: EdgeInsets.zero,
),
child: const Text(
'Sign Up',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
),
],
),
const SizedBox(height: 24),
Center(
child: GestureDetector(
onTap: () => Navigator.push(

View File

@ -3,6 +3,7 @@ import '../services/odoo_service.dart';
import '../theme/app_theme.dart';
import 'notifications_screen.dart';
import 'branches_screen.dart';
import 'settings_screen.dart';
class LoyaltyDashboard extends StatefulWidget {
final int partnerId;
@ -51,6 +52,10 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
icon: const Icon(Icons.notifications),
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const NotificationsScreen())),
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())),
),
const SizedBox(width: 8),
],
),
@ -98,7 +103,13 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
borderRadius: BorderRadius.zero, // Editorial block
),
child: Text(
'Gold Member',
(() {
final programName = (card['program_id']?[1] as String? ?? '').toLowerCase();
if (programName.contains('silver')) return 'Silver Member';
if (programName.contains('gold')) return 'Gold Member';
if (programName.contains('platinum')) return 'Platinum Member';
return 'Member';
})(),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: AppTheme.onSecondaryContainer,
fontWeight: FontWeight.bold,

View File

@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/odoo_service.dart';
import '../theme/app_theme.dart';
import 'login_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final _phraseController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
void _showDeleteConfirmationDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) {
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: const Text(
'Delete Account Permanently',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'WARNING: This is a permanent action. All your loyalty points, card tier history, and reward history will be deleted and cannot be recovered.',
style: TextStyle(
color: AppTheme.onSurface,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 16),
Text(
'To confirm, please type "DELETE MY ACCOUNT" in the field below:',
style: TextStyle(
color: AppTheme.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 8),
TextField(
controller: _phraseController,
decoration: const InputDecoration(
hintText: 'DELETE MY ACCOUNT',
),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Text(
'Enter your current password:',
style: TextStyle(
color: AppTheme.onSurfaceVariant,
fontSize: 13,
),
),
const SizedBox(height: 8),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
hintText: 'Password',
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () {
_phraseController.clear();
_passwordController.clear();
Navigator.of(context).pop();
},
child: const Text(
'Cancel',
style: TextStyle(
color: AppTheme.onSurface,
fontWeight: FontWeight.bold,
),
),
),
TextButton(
onPressed: _isLoading
? null
: () async {
final phrase = _phraseController.text.trim();
final password = _passwordController.text;
if (phrase != 'DELETE MY ACCOUNT') {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Verification phrase is incorrect.')),
);
return;
}
if (password.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter your password.')),
);
return;
}
setDialogState(() => _isLoading = true);
try {
final service = OdooService();
final response = await service.deleteAccount(password);
if (response != null && response['status'] == 'success') {
// Success! Clear local cache and redirect
final prefs = await SharedPreferences.getInstance();
await prefs.remove('odoo_session');
if (context.mounted) {
Navigator.of(context).pop(); // Close dialog
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(response['message'] ?? 'Account deleted successfully.')),
);
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LoginScreen()),
(route) => false,
);
}
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(response?['message'] ?? 'Deletion failed.')),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
setDialogState(() => _isLoading = false);
}
},
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.red),
)
: const Text(
'Delete My Account',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
),
],
);
},
);
},
);
}
@override
void dispose() {
_phraseController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Account Settings'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Privacy & Security',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppTheme.onSurface,
),
),
const SizedBox(height: 16),
Card(
color: AppTheme.surfaceContainerLow,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'How Account Deletion Works',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppTheme.onSurface,
),
),
const SizedBox(height: 12),
const Text(
'If you decide to delete your Mie Mapan loyalty account, please be aware that:\n\n'
'• All your loyalty cards and membership tiers will be permanently deactivated.\n'
'• All accumulated points balance will be reset to zero immediately.\n'
'• Your historical reward transactions and POS order line associations will be archived.\n'
'• Your personal profile information (Name, Phone, Birth Date, Gender, Email) will be anonymized in compliance with GDPR guidelines.\n\n'
'This process cannot be undone. Once deleted, you will have to register as a new member with zero points.',
style: TextStyle(
color: AppTheme.onSurfaceVariant,
fontSize: 14,
height: 1.5,
),
),
],
),
),
),
const SizedBox(height: 40),
ElevatedButton(
onPressed: _showDeleteConfirmationDialog,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFDAD4),
foregroundColor: const Color(0xFF410002),
side: const BorderSide(color: Color(0xFFBA1A1A), width: 1.5),
),
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: Text(
'Delete Account',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,337 @@
import 'package:flutter/material.dart';
import '../services/odoo_service.dart';
import '../services/config.dart';
import '../theme/app_theme.dart';
import '../widgets/agreement_dialog.dart';
class SignupScreen extends StatefulWidget {
const SignupScreen({super.key});
@override
State<SignupScreen> createState() => _SignupScreenState();
}
class _SignupScreenState extends State<SignupScreen> {
final _nameController = TextEditingController();
final _phoneController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
DateTime? _selectedDate;
String? _selectedGender;
bool _agreedToTerms = false;
bool _isLoading = false;
final List<String> _genderOptions = ['Male', 'Female'];
void _selectBirthDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: DateTime(2000, 1, 1),
firstDate: DateTime(1920),
lastDate: DateTime.now(),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: AppTheme.secondary,
onPrimary: Colors.white,
onSurface: AppTheme.onSurface,
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: AppTheme.secondary,
),
),
),
child: child!,
);
},
);
if (picked != null && picked != _selectedDate) {
setState(() {
_selectedDate = picked;
});
}
}
void _signUp() async {
final name = _nameController.text.trim();
final phone = _phoneController.text.trim();
final password = _passwordController.text;
final confirmPassword = _confirmPasswordController.text;
if (name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter your name.')),
);
return;
}
if (phone.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter your phone number.')),
);
return;
}
if (_selectedDate == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select your birth date.')),
);
return;
}
if (_selectedGender == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select your gender.')),
);
return;
}
if (password.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter a password.')),
);
return;
}
if (password != confirmPassword) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Passwords do not match.')),
);
return;
}
if (!_agreedToTerms) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('You must agree to the Terms & Conditions and Privacy Policy.')),
);
return;
}
setState(() => _isLoading = true);
try {
final formattedDate = "${_selectedDate!.year}-${_selectedDate!.month.toString().padLeft(2, '0')}-${_selectedDate!.day.toString().padLeft(2, '0')}";
final service = OdooService();
service.connect(AppConfig.odooUrl);
final response = await service.signUpMember(
name,
phone,
formattedDate,
_selectedGender!,
password,
);
if (response != null && response['status'] == 'success') {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(response['message'] ?? 'Registration successful!')),
);
Navigator.of(context).pop();
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(response?['message'] ?? 'Registration failed.')),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Become a Member'),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Join Mie Mapan Loyalty',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppTheme.onSurface,
fontSize: 28,
),
),
const SizedBox(height: 8),
Text(
'Register below to start earning points, unlocking culinary tiers, and tracking your loyalty level.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.onSurfaceVariant,
),
),
const SizedBox(height: 36),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Full Name',
),
),
const SizedBox(height: 20),
TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Phone Number',
hintText: 'e.g. 08123456789',
),
),
const SizedBox(height: 20),
InkWell(
onTap: _selectBirthDate,
child: Container(
color: AppTheme.surfaceContainer,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectedDate == null
? 'Select Birth Date'
: 'Birth Date: ${_selectedDate!.day.toString().padLeft(2, '0')}-${_selectedDate!.month.toString().padLeft(2, '0')}-${_selectedDate!.year}',
style: TextStyle(
color: _selectedDate == null ? AppTheme.onSurfaceVariant : AppTheme.onSurface,
fontSize: 16,
),
),
const Icon(Icons.calendar_today, color: AppTheme.onSurfaceVariant),
],
),
),
),
const SizedBox(height: 20),
DropdownButtonFormField<String>(
value: _selectedGender,
items: _genderOptions.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (val) {
setState(() {
_selectedGender = val;
});
},
decoration: const InputDecoration(
labelText: 'Gender',
),
dropdownColor: AppTheme.surfaceContainerLow,
style: const TextStyle(
color: AppTheme.onSurface,
fontSize: 16,
),
),
const SizedBox(height: 20),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
),
obscureText: true,
),
const SizedBox(height: 20),
TextField(
controller: _confirmPasswordController,
decoration: const InputDecoration(
labelText: 'Confirm Password',
),
obscureText: true,
),
const SizedBox(height: 24),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: _agreedToTerms,
activeColor: AppTheme.secondary,
onChanged: (val) {
setState(() {
_agreedToTerms = val ?? false;
});
},
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Wrap(
children: [
const Text('I agree to the ', style: TextStyle(color: AppTheme.onSurfaceVariant, fontSize: 13)),
GestureDetector(
onTap: () => AgreementDialog.show(
context,
'Terms & Conditions',
AgreementTexts.termsAndConditions
),
child: const Text(
'Terms & Conditions',
style: TextStyle(
color: AppTheme.secondary,
fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
fontSize: 13,
),
),
),
const Text(' and ', style: TextStyle(color: AppTheme.onSurfaceVariant, fontSize: 13)),
GestureDetector(
onTap: () => AgreementDialog.show(
context,
'Privacy Policy',
AgreementTexts.privacyPolicy
),
child: const Text(
'Privacy Policy',
style: TextStyle(
color: AppTheme.secondary,
fontWeight: FontWeight.bold,
decoration: TextDecoration.underline,
fontSize: 13,
),
),
),
],
),
),
),
],
),
const SizedBox(height: 36),
SizedBox(
height: 56,
child: _isLoading
? const Center(child: CircularProgressIndicator())
: ElevatedButton(
onPressed: _signUp,
child: const Text(
'Sign Up & Get Silver Card',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
);
}
}

4
lib/services/config.dart Normal file
View File

@ -0,0 +1,4 @@
class AppConfig {
static const String odooUrl = 'http://localhost:8069'; // Default local dev url
static const String odooDb = 'mapangroup_o19_11'; // Default local dev db
}

View File

@ -1,4 +1,5 @@
import 'package:odoo_rpc/odoo_rpc.dart';
import 'config.dart';
class OdooService {
static final OdooService _instance = OdooService._internal();
@ -19,8 +20,6 @@ class OdooService {
Future<List<dynamic>> getLoyaltyCards(int partnerId) async {
if (client == null) throw Exception("Connect to Odoo first");
// In Odoo 19, you might need to adjust the exact fields and model name
// depending on the installed modules (e.g. 'loyalty.card').
return await client!.callKw({
'model': 'loyalty.card',
'method': 'search_read',
@ -31,10 +30,59 @@ class OdooService {
}) as List<dynamic>;
}
Future<dynamic> signUpMember(
String name,
String phone,
String birthDate,
String gender,
String password,
) async {
if (client == null) throw Exception("Connect to Odoo first");
return await client!.callRPC(
'/api/loyalty/signup_member',
'call',
{
'name': name,
'phone': phone,
'birth_date': birthDate,
'gender': gender,
'password': password,
},
);
}
Future<dynamic> activateAccount(
String phone,
String birthDate,
String password,
) async {
if (client == null) throw Exception("Connect to Odoo first");
return await client!.callRPC(
'/api/loyalty/activate_account',
'call',
{
'phone': phone,
'birth_date': birthDate,
'password': password,
},
);
}
Future<dynamic> deleteAccount(String password) async {
if (client == null) throw Exception("Connect to Odoo first");
return await client!.callRPC(
'/api/loyalty/delete_account',
'call',
{
'password': password,
},
);
}
/// Fetch public branch information using our secure Odoo endpoint
/// This completely isolates the Admin API Key from the Flutter Source Code!
static Future<List<dynamic>> getPublicBranches() async {
final tempClient = OdooClient('https://erp.mapan.co.id');
final tempClient = OdooClient(AppConfig.odooUrl);
try {
final res = await tempClient.callRPC(
'/api/loyalty/branches',
@ -53,4 +101,3 @@ class OdooService {
}
}
}

View File

@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
class AgreementDialog extends StatelessWidget {
final String title;
final String content;
const AgreementDialog({
super.key,
required this.title,
required this.content,
});
static void show(BuildContext context, String title, String content) {
showDialog(
context: context,
builder: (context) => AgreementDialog(title: title, content: content),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
title,
style: const TextStyle(
color: AppTheme.onSurface,
fontWeight: FontWeight.bold,
),
),
content: SizedBox(
width: double.maxFinite,
child: SingleChildScrollView(
child: Text(
content,
style: const TextStyle(
color: AppTheme.onSurfaceVariant,
fontSize: 14,
height: 1.5,
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'Close',
style: TextStyle(
color: AppTheme.primary,
fontWeight: FontWeight.bold,
),
),
),
],
);
}
}
class AgreementTexts {
static const String termsAndConditions = '''
1. Acceptance of Terms
By registering and using the Mie Mapan Loyalty App, you agree to comply with and be bound by these Terms and Conditions.
2. Membership Eligibility
Membership is open to individuals aged 12 and above. Only one account per unique phone number is permitted.
3. Loyalty Points and Tiers
- Points are earned on qualified food and beverage purchases at Mie Mapan.
- The default starter tier is "Membership Silver".
- Points have a validity period and are non-transferable.
- Tiers are updated automatically based on yearly spending.
4. Account Management
- You are responsible for keeping your login credentials secure.
- Account information must be kept accurate.
5. Modifications to Service
Mie Mapan reserves the right to modify, suspend, or terminate the loyalty program or any rewards at any time with or without prior notice.
''';
static const String privacyPolicy = '''
1. Information We Collect
We collect the following personal information to operate the loyalty program:
- Full Name
- Phone Number (used as your unique login and for contact)
- Date of Birth (used for birthday rewards and age verification)
- Gender (used for personalized offers)
2. How We Use Your Data
Your data is used solely to:
- Maintain your loyalty account, points history, and membership level.
- Authenticate your login sessions in the app.
- Provide custom rewards and promotions.
3. Data Sharing and Protection
We do not share your personal data with third parties. Your credentials are securely stored using industry-standard hashing algorithms in our backend.
4. Account Deletion and GDPR
You have the right to request deletion of your account at any time. Account deletion is permanent and will result in the forfeiture of all accumulated points and membership tier history.
''';
}