From 8c92887bfb6b6265a49eb9f6fe3f3a3cc1369204 Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Sat, 13 Jun 2026 17:37:30 +0700 Subject: [PATCH] feat: add registration, activation, and settings workflows with centralized configuration and legal disclosures --- lib/screens/activation_screen.dart | 284 ++++++++++++++++++++++++ lib/screens/login_screen.dart | 66 +++++- lib/screens/loyalty_dashboard.dart | 13 +- lib/screens/settings_screen.dart | 260 ++++++++++++++++++++++ lib/screens/signup_screen.dart | 337 +++++++++++++++++++++++++++++ lib/services/config.dart | 4 + lib/services/odoo_service.dart | 55 ++++- lib/widgets/agreement_dialog.dart | 102 +++++++++ 8 files changed, 1105 insertions(+), 16 deletions(-) create mode 100644 lib/screens/activation_screen.dart create mode 100644 lib/screens/settings_screen.dart create mode 100644 lib/screens/signup_screen.dart create mode 100644 lib/services/config.dart create mode 100644 lib/widgets/agreement_dialog.dart diff --git a/lib/screens/activation_screen.dart b/lib/screens/activation_screen.dart new file mode 100644 index 0000000..0f86df5 --- /dev/null +++ b/lib/screens/activation_screen.dart @@ -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 createState() => _ActivationScreenState(); +} + +class _ActivationScreenState extends State { + 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, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 4245f3d..131d739 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -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 { - 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 { ), 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 { ), ), ), - 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( diff --git a/lib/screens/loyalty_dashboard.dart b/lib/screens/loyalty_dashboard.dart index 50c7007..7ab3840 100644 --- a/lib/screens/loyalty_dashboard.dart +++ b/lib/screens/loyalty_dashboard.dart @@ -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 { 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 { 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, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..d4949aa --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -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 createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + 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, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/signup_screen.dart b/lib/screens/signup_screen.dart new file mode 100644 index 0000000..82d6c3d --- /dev/null +++ b/lib/screens/signup_screen.dart @@ -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 createState() => _SignupScreenState(); +} + +class _SignupScreenState extends State { + final _nameController = TextEditingController(); + final _phoneController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + DateTime? _selectedDate; + String? _selectedGender; + bool _agreedToTerms = false; + bool _isLoading = false; + + final List _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( + value: _selectedGender, + items: _genderOptions.map((String value) { + return DropdownMenuItem( + 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, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/services/config.dart b/lib/services/config.dart new file mode 100644 index 0000000..133ca6e --- /dev/null +++ b/lib/services/config.dart @@ -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 +} diff --git a/lib/services/odoo_service.dart b/lib/services/odoo_service.dart index ff6171e..d1a801b 100644 --- a/lib/services/odoo_service.dart +++ b/lib/services/odoo_service.dart @@ -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> 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; } + Future 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 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 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> 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 { } } } - diff --git a/lib/widgets/agreement_dialog.dart b/lib/widgets/agreement_dialog.dart new file mode 100644 index 0000000..881ca63 --- /dev/null +++ b/lib/widgets/agreement_dialog.dart @@ -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. +'''; +}