diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 9c071df..34031d9 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"app_badge_plus","path":"/home/suherdy/.pub-cache/hosted/pub.dev/app_badge_plus-1.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/suherdy/.pub-cache/hosted/pub.dev/flutter_local_notifications-21.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/suherdy/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_apple","path":"/home/suherdy/.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.10/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_ios-6.4.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"workmanager_apple","path":"/home/suherdy/.pub-cache/hosted/pub.dev/workmanager_apple-0.9.1+2/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"app_badge_plus","path":"/home/suherdy/.pub-cache/hosted/pub.dev/app_badge_plus-1.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/suherdy/.pub-cache/hosted/pub.dev/flutter_local_notifications-21.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"/home/suherdy/.pub-cache/hosted/pub.dev/path_provider_android-2.2.22/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_android","path":"/home/suherdy/.pub-cache/hosted/pub.dev/permission_handler_android-13.0.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.21/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.29/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"workmanager_android","path":"/home/suherdy/.pub-cache/hosted/pub.dev/workmanager_android-0.9.0+2/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"app_badge_plus","path":"/home/suherdy/.pub-cache/hosted/pub.dev/app_badge_plus-1.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/suherdy/.pub-cache/hosted/pub.dev/flutter_local_notifications-21.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/suherdy/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"flutter_local_notifications_linux","path":"/home/suherdy/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-8.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/suherdy/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"flutter_local_notifications_windows","path":"/home/suherdy/.pub-cache/hosted/pub.dev/flutter_local_notifications_windows-3.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/suherdy/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_windows","path":"/home/suherdy/.pub-cache/hosted/pub.dev/permission_handler_windows-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"permission_handler_html","path":"/home/suherdy/.pub-cache/hosted/pub.dev/permission_handler_html-0.1.3+5/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.2/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"app_badge_plus","dependencies":[]},{"name":"flutter_local_notifications","dependencies":["flutter_local_notifications_linux","flutter_local_notifications_windows"]},{"name":"flutter_local_notifications_linux","dependencies":[]},{"name":"flutter_local_notifications_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"permission_handler","dependencies":["permission_handler_android","permission_handler_apple","permission_handler_html","permission_handler_windows"]},{"name":"permission_handler_android","dependencies":[]},{"name":"permission_handler_apple","dependencies":[]},{"name":"permission_handler_html","dependencies":[]},{"name":"permission_handler_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]},{"name":"workmanager","dependencies":["workmanager_android","workmanager_apple"]},{"name":"workmanager_android","dependencies":[]},{"name":"workmanager_apple","dependencies":[]}],"date_created":"2026-06-13 21:28:54.078112","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"app_badge_plus","path":"/home/suherdy/.pub-cache/hosted/pub.dev/app_badge_plus-1.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/suherdy/.pub-cache/hosted/pub.dev/flutter_local_notifications-21.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"/home/suherdy/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/suherdy/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_apple","path":"/home/suherdy/.pub-cache/hosted/pub.dev/permission_handler_apple-9.4.10/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_ios-6.4.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"workmanager_apple","path":"/home/suherdy/.pub-cache/hosted/pub.dev/workmanager_apple-0.9.1+2/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"app_badge_plus","path":"/home/suherdy/.pub-cache/hosted/pub.dev/app_badge_plus-1.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/suherdy/.pub-cache/hosted/pub.dev/flutter_local_notifications-21.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"/home/suherdy/.pub-cache/hosted/pub.dev/geolocator_android-4.6.2/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"/home/suherdy/.pub-cache/hosted/pub.dev/path_provider_android-2.2.22/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_android","path":"/home/suherdy/.pub-cache/hosted/pub.dev/permission_handler_android-13.0.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_android","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_android-2.4.21/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_android-6.3.29/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"workmanager_android","path":"/home/suherdy/.pub-cache/hosted/pub.dev/workmanager_android-0.9.0+2/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"app_badge_plus","path":"/home/suherdy/.pub-cache/hosted/pub.dev/app_badge_plus-1.3.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_local_notifications","path":"/home/suherdy/.pub-cache/hosted/pub.dev/flutter_local_notifications-21.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"/home/suherdy/.pub-cache/hosted/pub.dev/geolocator_apple-2.3.14/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"/home/suherdy/.pub-cache/hosted/pub.dev/path_provider_foundation-2.6.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_foundation-2.5.6/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_macos-3.2.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"flutter_local_notifications_linux","path":"/home/suherdy/.pub-cache/hosted/pub.dev/flutter_local_notifications_linux-8.0.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"/home/suherdy/.pub-cache/hosted/pub.dev/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_linux-2.4.1/","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"url_launcher_linux","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.2/","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"flutter_local_notifications_windows","path":"/home/suherdy/.pub-cache/hosted/pub.dev/flutter_local_notifications_windows-3.0.0/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"/home/suherdy/.pub-cache/hosted/pub.dev/geolocator_windows-0.2.5/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"/home/suherdy/.pub-cache/hosted/pub.dev/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_windows","path":"/home/suherdy/.pub-cache/hosted/pub.dev/permission_handler_windows-0.2.1/","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_windows-2.4.1/","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"url_launcher_windows","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_windows-3.1.5/","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"geolocator_web","path":"/home/suherdy/.pub-cache/hosted/pub.dev/geolocator_web-4.1.4/","dependencies":[],"dev_dependency":false},{"name":"permission_handler_html","path":"/home/suherdy/.pub-cache/hosted/pub.dev/permission_handler_html-0.1.3+5/","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"/home/suherdy/.pub-cache/hosted/pub.dev/shared_preferences_web-2.4.3/","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"/home/suherdy/.pub-cache/hosted/pub.dev/url_launcher_web-2.4.2/","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"app_badge_plus","dependencies":[]},{"name":"flutter_local_notifications","dependencies":["flutter_local_notifications_linux","flutter_local_notifications_windows"]},{"name":"flutter_local_notifications_linux","dependencies":[]},{"name":"flutter_local_notifications_windows","dependencies":[]},{"name":"geolocator","dependencies":["geolocator_android","geolocator_apple","geolocator_web","geolocator_windows"]},{"name":"geolocator_android","dependencies":[]},{"name":"geolocator_apple","dependencies":[]},{"name":"geolocator_web","dependencies":[]},{"name":"geolocator_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"permission_handler","dependencies":["permission_handler_android","permission_handler_apple","permission_handler_html","permission_handler_windows"]},{"name":"permission_handler_android","dependencies":[]},{"name":"permission_handler_apple","dependencies":[]},{"name":"permission_handler_html","dependencies":[]},{"name":"permission_handler_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]},{"name":"workmanager","dependencies":["workmanager_android","workmanager_apple"]},{"name":"workmanager_android","dependencies":[]},{"name":"workmanager_apple","dependencies":[]}],"date_created":"2026-06-14 08:33:22.634142","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4e203d5..2747bfc 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + createState() => _AccountScreenState(); +} + +class _AccountScreenState extends State { + final _phraseController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isLoading = false; + + String _aboutUsUrl = ''; + String _contactUsUrl = ''; + + @override + void initState() { + super.initState(); + _loadAppConfig(); + } + + Future _loadAppConfig() async { + try { + final config = await OdooService.getAppConfig(); + if (mounted) { + setState(() { + _aboutUsUrl = config['about_us_url'] ?? ''; + _contactUsUrl = config['contact_us_url'] ?? ''; + }); + } + } catch (_) {} + } + + Future _launchUrl(String url) async { + if (url.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('URL not configured yet.')), + ); + return; + } + final uri = Uri.tryParse(url); + if (uri != null && await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open link.')), + ); + } + } + } + + void _showTerms() { + AgreementDialog.show(context, 'Terms & Conditions', AgreementTexts.termsAndConditions); + } + + void _showPrivacy() { + AgreementDialog.show(context, 'Privacy Policy', AgreementTexts.privacyPolicy); + } + + void _logout() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('odoo_session'); + await prefs.remove('last_seen_notification_id'); + await prefs.remove('last_device_notified_id'); + await prefs.remove('read_notification_ids'); + await NotificationService().clearBadge(); + if (mounted) { + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginScreen()), + (route) => false, + ); + } + } + + void _showDeleteConfirmationDialog() { + _phraseController.clear(); + _passwordController.clear(); + 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, type "DELETE MY ACCOUNT" 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: () => 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') { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('odoo_session'); + if (context.mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(response['message'] ?? 'Account deleted.')), + ); + 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 SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 16), + + // ── Info Section ───────────────────────────────────────────── + _SectionHeader(label: 'Info'), + _MenuItem( + icon: Icons.info_outline_rounded, + label: 'About Us', + onTap: () => _launchUrl(_aboutUsUrl), + ), + _MenuItem( + icon: Icons.phone_rounded, + label: 'Contact Us', + onTap: () => _launchUrl(_contactUsUrl), + ), + + const SizedBox(height: 8), + + // ── Legal Section ──────────────────────────────────────────── + _SectionHeader(label: 'Legal'), + _MenuItem( + icon: Icons.description_outlined, + label: 'Terms & Conditions', + onTap: _showTerms, + ), + _MenuItem( + icon: Icons.lock_outline_rounded, + label: 'Privacy Policy', + onTap: _showPrivacy, + ), + + const SizedBox(height: 8), + + // ── Account Section ────────────────────────────────────────── + _SectionHeader(label: 'Account'), + _MenuItem( + icon: Icons.logout_rounded, + label: 'Log Out', + onTap: _logout, + ), + _MenuItem( + icon: Icons.delete_outline_rounded, + label: 'Delete Account', + labelColor: const Color(0xFFB02500), + iconColor: const Color(0xFFB02500), + onTap: _showDeleteConfirmationDialog, + ), + + const SizedBox(height: 32), + ], + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + final String label; + const _SectionHeader({required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + label.toUpperCase(), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.onSurfaceVariant, + letterSpacing: 1.2, + fontSize: 11, + ), + ), + ), + ); + } +} + +class _MenuItem extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + final Color? labelColor; + final Color? iconColor; + + const _MenuItem({ + required this.icon, + required this.label, + required this.onTap, + this.labelColor, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: AppTheme.surfaceContainerLowest, + child: InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: AppTheme.surfaceContainer, width: 1), + ), + ), + child: Row( + children: [ + Icon(icon, + size: 22, + color: iconColor ?? AppTheme.onSurfaceVariant), + const SizedBox(width: 16), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: labelColor ?? AppTheme.onSurface, + ), + ), + ), + Icon(Icons.chevron_right, + size: 20, color: AppTheme.outlineVariant), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/branches_screen.dart b/lib/screens/branches_screen.dart index d72715a..6502ef1 100644 --- a/lib/screens/branches_screen.dart +++ b/lib/screens/branches_screen.dart @@ -1,4 +1,6 @@ +import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; import 'package:url_launcher/url_launcher.dart'; import '../services/odoo_service.dart'; import '../theme/app_theme.dart'; @@ -13,37 +15,121 @@ class BranchesScreen extends StatefulWidget { class _BranchesScreenState extends State { List _branches = []; bool _isLoading = true; + Position? _userPosition; + bool _locationDenied = false; @override void initState() { super.initState(); - _fetchBranches(); + _fetchBranchesWithLocation(); } - Future _fetchBranches() async { + Future _fetchBranchesWithLocation() async { + setState(() => _isLoading = true); + + // Try to get user location + Position? pos; + try { + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (serviceEnabled) { + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.whileInUse || + permission == LocationPermission.always) { + pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.medium, + timeLimit: Duration(seconds: 8), + ), + ); + } + } + } catch (_) { + // Location not available — proceed without it + } + + // Fetch branches from Odoo try { final branches = await OdooService.getPublicBranches(); + if (mounted) { setState(() { - _branches = branches; + _userPosition = pos; + _locationDenied = pos == null; + + if (pos != null) { + // Sort branches by distance from user + _branches = List.from(branches) + ..sort((a, b) { + final p = pos!; + final da = _distanceTo(p, a); + final db = _distanceTo(p, b); + return da.compareTo(db); + }); + } else { + // Fallback: alphabetical sort + _branches = List.from(branches) + ..sort((a, b) => (a['name'] as String? ?? '') + .compareTo(b['name'] as String? ?? '')); + } _isLoading = false; }); } } catch (e) { if (mounted) { setState(() => _isLoading = false); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading branches. Check connection.'))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Error loading branches. Check connection.')), + ); } } } + /// Haversine formula — returns distance in kilometres. + double _distanceTo(Position pos, dynamic branch) { + final lat = _toDouble(branch['partner_latitude']); + final lng = _toDouble(branch['partner_longitude']); + if (lat == 0.0 && lng == 0.0) return double.maxFinite; + + const R = 6371.0; + final dLat = _degToRad(lat - pos.latitude); + final dLng = _degToRad(lng - pos.longitude); + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(_degToRad(pos.latitude)) * + cos(_degToRad(lat)) * + sin(dLng / 2) * + sin(dLng / 2); + final c = 2 * atan2(sqrt(a), sqrt(1 - a)); + return R * c; + } + + double _degToRad(double deg) => deg * (pi / 180); + + double _toDouble(dynamic val) { + if (val == null || val == false) return 0.0; + if (val is num) return val.toDouble(); + return double.tryParse(val.toString()) ?? 0.0; + } + + String _formatDistance(double km) { + if (km == double.maxFinite) return ''; + if (km < 1) return '${(km * 1000).toStringAsFixed(0)} m'; + return '${km.toStringAsFixed(1)} km'; + } + Future _launchMaps(String queryTerm) async { final query = Uri.encodeComponent(queryTerm); final url = Uri.parse('https://www.google.com/maps/search/?api=1&query=$query'); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } else { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not open map.'))); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open map.')), + ); + } } } @@ -57,85 +143,193 @@ class _BranchesScreenState extends State { if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); } else { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not open WhatsApp.'))); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open WhatsApp.')), + ); + } } } @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Find Branch')), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _branches.isEmpty - ? const Center(child: Text('No branches available.', style: TextStyle(fontSize: 16))) - : ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - itemCount: _branches.length, - itemBuilder: (context, index) { - final branch = _branches[index]; - - final street = branch['street'] != null && branch['street'] != false ? branch['street'] : ''; - final city = branch['city'] != null && branch['city'] != false ? branch['city'] : ''; - final phone = branch['phone'] != null && branch['phone'] != false ? branch['phone'] : ''; - - final addressParts = [street, city].where((e) => e.toString().isNotEmpty).join(', '); - - return Container( - margin: const EdgeInsets.only(bottom: 16), - decoration: const BoxDecoration( - color: AppTheme.surfaceContainerLow, - borderRadius: BorderRadius.zero, - // Spec rules: "Don't use standard drop shadows" - ), - child: ListTile( - contentPadding: const EdgeInsets.all(16), - onTap: () => _launchMaps('${branch['name']} ${addressParts}'), - leading: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.secondaryContainer.withOpacity(0.2), - shape: BoxShape.rectangle, - ), - child: const Icon(Icons.storefront, color: AppTheme.secondary), - ), - title: Text( - branch['name'] ?? 'Mapan Branch', - style: Theme.of(context).textTheme.titleMedium, - ), - subtitle: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - addressParts.isEmpty ? 'No address specified' : addressParts, - style: Theme.of(context).textTheme.bodyMedium, + return _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _fetchBranchesWithLocation, + child: Column( + children: [ + // Location status banner + if (_locationDenied) + Container( + width: double.infinity, + color: AppTheme.surfaceContainerLow, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + const Icon(Icons.location_off, size: 16, color: AppTheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Location not available. Showing branches alphabetically.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.onSurfaceVariant, ), - if (phone.isNotEmpty) ...[ - const SizedBox(height: 4), - Row( + ), + ), + ], + ), + ) + else if (_userPosition != null) + Container( + width: double.infinity, + color: AppTheme.surfaceContainerLow, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + const Icon(Icons.my_location, size: 16, color: AppTheme.secondary), + const SizedBox(width: 8), + Text( + 'Sorted by distance from your location', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.onSurfaceVariant, + ), + ), + ], + ), + ), + + // Branch list + Expanded( + child: _branches.isEmpty + ? const Center( + child: Text('No branches available.', style: TextStyle(fontSize: 16)), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: _branches.length, + itemBuilder: (context, index) { + final branch = _branches[index]; + + final street = branch['street'] != null && branch['street'] != false + ? branch['street'] + : ''; + final city = branch['city'] != null && branch['city'] != false + ? branch['city'] + : ''; + final phone = branch['phone'] != null && branch['phone'] != false + ? branch['phone'] + : ''; + + final addressParts = [street, city] + .where((e) => e.toString().isNotEmpty) + .join(', '); + + final distance = _userPosition != null + ? _distanceTo(_userPosition!, branch) + : null; + final distanceLabel = + distance != null ? _formatDistance(distance) : ''; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: const BoxDecoration( + color: AppTheme.surfaceContainerLow, + borderRadius: BorderRadius.zero, + ), + child: ListTile( + contentPadding: const EdgeInsets.all(16), + onTap: () => _launchMaps( + '${branch['name']} $addressParts'), + leading: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.secondaryContainer + .withValues(alpha: 0.2), + shape: BoxShape.rectangle, + ), + child: const Icon(Icons.storefront, + color: AppTheme.secondary), + ), + title: Row( + children: [ + Expanded( + child: Text( + branch['name'] ?? 'Mapan Branch', + style: Theme.of(context) + .textTheme + .titleMedium, + ), + ), + if (distanceLabel.isNotEmpty) + Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), + decoration: const BoxDecoration( + color: AppTheme.secondaryContainer, + ), + child: Text( + distanceLabel, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: AppTheme.onSecondaryContainer, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.phone, size: 14, color: AppTheme.onSurfaceVariant), - const SizedBox(width: 4), - Text(phone, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: AppTheme.onSurfaceVariant)), + Text( + addressParts.isEmpty + ? 'No address specified' + : addressParts, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (phone.isNotEmpty) ...[ + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.phone, + size: 14, + color: AppTheme.onSurfaceVariant), + const SizedBox(width: 4), + Text(phone, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: AppTheme.onSurfaceVariant)), + ], + ), + ] ], ), - ] - ], - ), - ), - trailing: phone.isNotEmpty - ? IconButton( - icon: const Icon(Icons.chat_bubble, color: AppTheme.onSurface), - onPressed: () => _launchWhatsApp(phone), - tooltip: 'Chat on WhatsApp', - ) - : const Icon(Icons.chevron_right, color: AppTheme.onSurfaceVariant), + ), + trailing: phone.isNotEmpty + ? IconButton( + icon: const Icon(Icons.chat_bubble, + color: AppTheme.onSurface), + onPressed: () => _launchWhatsApp(phone), + tooltip: 'Chat on WhatsApp', + ) + : const Icon(Icons.chevron_right, + color: AppTheme.onSurfaceVariant), + ), + ); + }, ), - ); - }, ), - ); + ], + ), + ); } } diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 126da8f..5da9175 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -4,7 +4,7 @@ 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 'main_shell.dart'; import 'branches_screen.dart'; import 'activation_screen.dart'; import 'signup_screen.dart'; @@ -43,7 +43,7 @@ class _LoginScreenState extends State { Navigator.pushReplacement( context, MaterialPageRoute( - builder: (_) => LoyaltyDashboard(partnerId: session.partnerId), + builder: (_) => MainShell(partnerId: session.partnerId), ), ); } diff --git a/lib/screens/loyalty_dashboard.dart b/lib/screens/loyalty_dashboard.dart index ed4eb69..cb13e5f 100644 --- a/lib/screens/loyalty_dashboard.dart +++ b/lib/screens/loyalty_dashboard.dart @@ -1,13 +1,12 @@ -import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import '../services/odoo_service.dart'; -import '../services/notification_service.dart'; import '../theme/app_theme.dart'; -import 'notifications_screen.dart'; -import 'branches_screen.dart'; -import 'settings_screen.dart'; +import '../widgets/carousel_widget.dart'; +import '../widgets/promo_card_widget.dart'; +import '../widgets/subscription_list_widget.dart'; +/// Home tab — shows loyalty card, subscriptions, carousel, and promo highlights. +/// Notification polling and AppBar are handled by MainShell. class LoyaltyDashboard extends StatefulWidget { final int partnerId; const LoyaltyDashboard({super.key, required this.partnerId}); @@ -18,249 +17,166 @@ class LoyaltyDashboard extends StatefulWidget { class _LoyaltyDashboardState extends State { List _loyaltyCards = []; + List _subscriptions = []; + List _carouselSlides = []; + List _promos = []; bool _isLoading = true; - int _unreadNotificationCount = 0; - Timer? _notificationTimer; - - // Shared pref keys - static const _kLastNotified = 'last_device_notified_id'; @override void initState() { super.initState(); - _fetchLoyaltyData(); - _fetchNotificationCount(); - _notificationTimer = Timer.periodic(const Duration(seconds: 10), (_) { - _fetchNotificationCount(); - }); + _fetchAll(); } - @override - void dispose() { - _notificationTimer?.cancel(); - super.dispose(); - } - - Future _fetchNotificationCount() async { + Future _fetchAll() async { + setState(() => _isLoading = true); try { - final client = OdooService().client; - if (client == null) return; + final results = await Future.wait([ + OdooService().getLoyaltyCards(widget.partnerId), + OdooService().getSubscriptionCards(widget.partnerId), + OdooService().getCmsContent(), + ]); - final response = await client.callRPC( - '/api/loyalty/fetch_notifications', - 'call', - {'last_id': 0}, - ); + final cards = results[0] as List; + final subs = results[1] as List; + final cms = results[2] as Map; - if (response != null && response['status'] == 'success') { - final List notifs = response['data'] ?? []; - final prefs = await SharedPreferences.getInstance(); - final lastNotifiedId = prefs.getInt(_kLastNotified) ?? 0; - - // Check read list - final readIds = prefs.getStringList('read_notification_ids'); - int unreadCount = 0; - - if (readIds == null) { - final initialRead = notifs.map((n) => (n['id'] as int? ?? 0).toString()).toList(); - await prefs.setStringList('read_notification_ids', initialRead); - unreadCount = 0; - } else { - unreadCount = notifs - .where((n) => !readIds.contains((n['id'] as int? ?? 0).toString())) - .length; - } - - int highestNewId = lastNotifiedId; - final List toNotify = []; - - for (var notif in notifs) { - final id = notif['id'] as int? ?? 0; - if (id > lastNotifiedId) { - toNotify.add(notif); - if (id > highestNewId) highestNewId = id; - } - } - - // Show device tray notifications for any not yet shown - if (toNotify.isNotEmpty) { - final notifService = NotificationService(); - for (final notif in toNotify) { - await notifService.showNotification( - id: notif['id'] as int, - title: notif['title'] ?? 'Mie Mapan', - body: notif['body'] ?? '', - ); - } - await prefs.setInt(_kLastNotified, highestNewId); - } - - // Always update system badge to match the unread count - await NotificationService().setBadge(unreadCount); - - if (mounted) { - setState(() => _unreadNotificationCount = unreadCount); - } + if (mounted) { + setState(() { + _loyaltyCards = cards; + _subscriptions = subs; + _carouselSlides = (cms['carousel'] as List?) ?? []; + _promos = (cms['promos'] as List?) ?? []; + _isLoading = false; + }); } - } catch (e) { - // ignore - } - } - - Future _fetchLoyaltyData() async { - try { - final cards = await OdooService().getLoyaltyCards(widget.partnerId); - await _fetchNotificationCount(); - setState(() { - _loyaltyCards = cards; - _isLoading = false; - }); } catch (e) { if (mounted) { setState(() => _isLoading = false); - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading loyalty cards: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error loading data: $e')), + ); } } } - @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('My Rewards'), - actions: [ - IconButton( - icon: const Icon(Icons.storefront), - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const BranchesScreen())), - ), - Stack( - clipBehavior: Clip.none, - children: [ - IconButton( - icon: const Icon(Icons.notifications), - onPressed: () async { - await Navigator.push(context, MaterialPageRoute(builder: (_) => const NotificationsScreen())); - _fetchNotificationCount(); - }, + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return RefreshIndicator( + onRefresh: _fetchAll, + child: ListView( + children: [ + // ── Loyalty Card ───────────────────────────────────────────── + if (_loyaltyCards.isEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 0), + child: Text( + 'No active loyalty card yet.', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, ), - if (_unreadNotificationCount > 0) - Positioned( - right: 8, - top: 8, - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - constraints: const BoxConstraints( - minWidth: 16, - minHeight: 16, - ), - child: Text( - '$_unreadNotificationCount', - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - ), - ), - ], - ), - IconButton( - icon: const Icon(Icons.settings), - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())), - ), - const SizedBox(width: 8), + ) + else + ..._loyaltyCards.map((card) => _LoyaltyCardTile(card: card)), + + // ── Subscriptions ───────────────────────────────────────────── + if (_subscriptions.isNotEmpty) + SubscriptionListWidget(subscriptions: _subscriptions), + + const SizedBox(height: 20), + + // ── Carousel ────────────────────────────────────────────────── + if (_carouselSlides.isNotEmpty) ...[ + CarouselWidget(slides: _carouselSlides), + const SizedBox(height: 24), + ], + + // ── Promo Highlights ───────────────────────────────────────── + if (_promos.isNotEmpty) ...[ + PromoCardRow(promos: _promos), + const SizedBox(height: 24), + ], + ], + ), + ); + } +} + +class _LoyaltyCardTile extends StatelessWidget { + final dynamic card; + const _LoyaltyCardTile({required this.card}); + + @override + Widget build(BuildContext context) { + final programName = (card['program_id']?[1] as String? ?? '').toLowerCase(); + String tier = 'Member'; + if (programName.contains('silver')) tier = 'Silver Member'; + if (programName.contains('gold')) tier = 'Gold Member'; + if (programName.contains('platinum')) tier = 'Platinum Member'; + + return Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 0), + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + color: AppTheme.surfaceContainerHighest, + borderRadius: BorderRadius.zero, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + '${card['program_id']?[1] ?? 'Loyalty Program'}', + style: Theme.of(context).textTheme.titleLarge, + softWrap: true, + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: const BoxDecoration( + color: AppTheme.secondaryContainer, + borderRadius: BorderRadius.zero, + ), + child: Text( + tier, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.onSecondaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + Text('Membership Code', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 4), + Text('${card['code'] ?? 'N/A'}', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('Available Points', + style: Theme.of(context).textTheme.bodyMedium), + Text( + '${card['points'] ?? 0}', + style: Theme.of(context).textTheme.displayMedium?.copyWith( + color: AppTheme.primary, + ), + ), + ], + ), ], ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : RefreshIndicator( - onRefresh: _fetchLoyaltyData, - child: _loyaltyCards.isEmpty - ? Center( - child: Text( - 'No active rewards yet.', - style: Theme.of(context).textTheme.titleLarge, - ), - ) - : ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 40.0), - itemCount: _loyaltyCards.length, - itemBuilder: (context, index) { - final card = _loyaltyCards[index]; - return Container( - margin: const EdgeInsets.only(bottom: 40), - padding: const EdgeInsets.all(32), - decoration: const BoxDecoration( - color: AppTheme.surfaceContainerHighest, // Soft Lift without shadow - borderRadius: BorderRadius.zero, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - '${card['program_id']?[1] ?? 'Loyalty Program'}', - style: Theme.of(context).textTheme.titleLarge, - softWrap: true, - ), - ), - const SizedBox(width: 16), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: const BoxDecoration( - color: AppTheme.secondaryContainer, - borderRadius: BorderRadius.zero, // Editorial block - ), - child: Text( - (() { - 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, - ), - ), - ), - ], - ), - const SizedBox(height: 32), - Text('Membership Code', style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: 4), - Text('${card['code'] ?? 'N/A'}', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text('Available Points', style: Theme.of(context).textTheme.bodyMedium), - Text( - '${card['points'] ?? 0}', - style: Theme.of(context).textTheme.displayMedium?.copyWith( - color: AppTheme.primary, - ), - ), - ], - ), - ], - ), - ); - }, - ), - ), ); } } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart new file mode 100644 index 0000000..29f012d --- /dev/null +++ b/lib/screens/main_shell.dart @@ -0,0 +1,204 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../services/odoo_service.dart'; +import '../services/notification_service.dart'; +import '../services/theme_manager.dart'; +import '../theme/app_theme.dart'; +import 'notifications_screen.dart'; +import 'loyalty_dashboard.dart'; +import 'branches_screen.dart'; +import 'orders_screen.dart'; +import 'account_screen.dart'; + +class MainShell extends StatefulWidget { + final int partnerId; + const MainShell({super.key, required this.partnerId}); + + @override + State createState() => _MainShellState(); +} + +class _MainShellState extends State { + int _currentIndex = 0; + int _unreadNotificationCount = 0; + Timer? _notificationTimer; + + static const _kLastNotified = 'last_device_notified_id'; + + late final List _pages; + + @override + void initState() { + super.initState(); + _pages = [ + LoyaltyDashboard(partnerId: widget.partnerId), + const BranchesScreen(), + const OrdersScreen(), + const AccountScreen(), + ]; + _fetchNotificationCount(); + _notificationTimer = Timer.periodic(const Duration(seconds: 30), (_) { + _fetchNotificationCount(); + }); + } + + @override + void dispose() { + _notificationTimer?.cancel(); + super.dispose(); + } + + Future _fetchNotificationCount() async { + try { + final client = OdooService().client; + if (client == null) return; + + final response = await client.callRPC( + '/api/loyalty/fetch_notifications', + 'call', + {'last_id': 0}, + ); + + if (response != null && response['status'] == 'success') { + final List notifs = response['data'] ?? []; + final prefs = await SharedPreferences.getInstance(); + final lastNotifiedId = prefs.getInt(_kLastNotified) ?? 0; + final readIds = prefs.getStringList('read_notification_ids'); + + int unreadCount = 0; + if (readIds == null) { + final initialRead = notifs.map((n) => (n['id'] as int? ?? 0).toString()).toList(); + await prefs.setStringList('read_notification_ids', initialRead); + unreadCount = 0; + } else { + unreadCount = notifs + .where((n) => !readIds.contains((n['id'] as int? ?? 0).toString())) + .length; + } + + int highestNewId = lastNotifiedId; + final List toNotify = []; + for (var notif in notifs) { + final id = notif['id'] as int? ?? 0; + if (id > lastNotifiedId) { + toNotify.add(notif); + if (id > highestNewId) highestNewId = id; + } + } + + if (toNotify.isNotEmpty) { + final notifService = NotificationService(); + for (final notif in toNotify) { + await notifService.showNotification( + id: notif['id'] as int, + title: notif['title'] ?? 'Mie Mapan', + body: notif['body'] ?? '', + ); + } + await prefs.setInt(_kLastNotified, highestNewId); + } + + await NotificationService().setBadge(unreadCount); + + if (mounted) { + setState(() => _unreadNotificationCount = unreadCount); + } + } + } catch (e) { + // ignore + } + } + + @override + Widget build(BuildContext context) { + final navLabels = ['Home', 'Branches', 'Orders', 'Account']; + final navIcons = [ + Icons.home_rounded, + Icons.location_on_rounded, + Icons.receipt_long_rounded, + Icons.person_rounded, + ]; + + final colorScheme = Theme.of(context).colorScheme; + + return Scaffold( + appBar: AppBar( + title: ThemeManager.instance.brandLogo.isNotEmpty + ? Image.memory( + base64Decode(ThemeManager.instance.brandLogo), + height: 36, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) => const Text('Mie Mapan'), + ) + : const Text('Mie Mapan'), + actions: [ + Stack( + clipBehavior: Clip.none, + children: [ + IconButton( + icon: const Icon(Icons.notifications_rounded), + tooltip: 'Notifications', + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const NotificationsScreen()), + ); + _fetchNotificationCount(); + }, + ), + if (_unreadNotificationCount > 0) + Positioned( + right: 6, + top: 6, + child: Container( + padding: const EdgeInsets.all(3), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + constraints: const BoxConstraints(minWidth: 18, minHeight: 18), + child: Text( + _unreadNotificationCount > 99 + ? '99+' + : '$_unreadNotificationCount', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + const SizedBox(width: 4), + ], + ), + body: IndexedStack( + index: _currentIndex, + children: _pages, + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: (index) { + setState(() => _currentIndex = index); + }, + backgroundColor: AppTheme.surfaceContainerLowest, + indicatorColor: colorScheme.primary, + destinations: List.generate(4, (i) { + return NavigationDestination( + icon: Icon(navIcons[i], + color: i == _currentIndex + ? colorScheme.onSecondaryContainer + : colorScheme.onSurfaceVariant), + selectedIcon: Icon(navIcons[i], color: colorScheme.onSecondaryContainer), + label: navLabels[i], + ); + }), + ), + ); + } +} diff --git a/lib/screens/notification_detail_screen.dart b/lib/screens/notification_detail_screen.dart index ead02aa..4cdcdca 100644 --- a/lib/screens/notification_detail_screen.dart +++ b/lib/screens/notification_detail_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; import '../theme/app_theme.dart'; import '../services/odoo_service.dart'; @@ -10,20 +11,19 @@ class NotificationDetailScreen extends StatelessWidget { @override Widget build(BuildContext context) { final title = notif['title'] as String? ?? 'Notice'; - final body = notif['body'] as String? ?? ''; + final bodyHtml = notif['body'] as String? ?? ''; return Scaffold( appBar: AppBar( title: const Text('Notification Detail'), ), body: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Header icon block + // Header block Container( - padding: const EdgeInsets.symmetric(vertical: 32), + padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24), color: AppTheme.surfaceContainerLow, child: Column( children: [ @@ -40,89 +40,115 @@ class NotificationDetailScreen extends StatelessWidget { ), ), const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - title, - textAlign: TextAlign.center, - style: - Theme.of(context).textTheme.headlineMedium?.copyWith( - color: AppTheme.onSurface, - fontSize: 20, - ), - ), + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: AppTheme.onSurface, + fontSize: 20, + ), ), ], ), ), + // Full image (authenticated) if (notif['has_image'] == true) ...[ - const SizedBox(height: 24), - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - OdooService().notificationImageUrl(notif['id'] as int), - headers: { - 'Cookie': OdooService().sessionCookie, - }, - fit: BoxFit.cover, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - height: 200, - color: AppTheme.surfaceContainerLow, - child: const Center(child: CircularProgressIndicator()), - ); - }, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 150, - color: AppTheme.surfaceContainerLow, - child: const Center( - child: Icon(Icons.broken_image_outlined, size: 48, color: AppTheme.onSurfaceVariant), - ), - ); - }, + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: ClipRect( + child: Image.network( + OdooService().notificationImageUrl(notif['id'] as int), + headers: {'Cookie': OdooService().sessionCookie}, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + height: 200, + color: AppTheme.surfaceContainerLow, + child: const Center(child: CircularProgressIndicator()), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 150, + color: AppTheme.surfaceContainerLow, + child: const Center( + child: Icon(Icons.broken_image_outlined, + size: 48, color: AppTheme.onSurfaceVariant), + ), + ); + }, + ), ), ), ], - const SizedBox(height: 24), + const SizedBox(height: 20), - // Body label - Text( - 'Message', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: AppTheme.onSurfaceVariant, - letterSpacing: 1.2, - ), + // Message label + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + 'MESSAGE', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.onSurfaceVariant, + letterSpacing: 1.2, + ), + ), + ), + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Divider( + color: AppTheme.surfaceContainerHighest, + thickness: 2, + height: 2, + ), ), const SizedBox(height: 8), - // Divider line (editorial style) - const Divider( - color: AppTheme.surfaceContainerHighest, - thickness: 2, - height: 2, - ), - const SizedBox(height: 16), - - // Body text - body.isEmpty - ? Text( - 'No additional details.', - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), + // Rich HTML body + bodyHtml.isEmpty + ? Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'No additional details.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), ) - : Text( - body, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Html( + data: bodyHtml, + style: { + 'body': Style( color: AppTheme.onSurface, - height: 1.7, + fontFamily: 'Manrope', + fontSize: FontSize(15), + lineHeight: const LineHeight(1.7), ), + 'p': Style(margin: Margins.only(bottom: 12)), + 'h1': Style( + color: AppTheme.onSurface, + fontSize: FontSize(22), + fontWeight: FontWeight.bold, + ), + 'h2': Style( + color: AppTheme.onSurface, + fontSize: FontSize(18), + fontWeight: FontWeight.bold, + ), + 'a': Style(color: AppTheme.secondary), + }, + ), ), + + const SizedBox(height: 40), ], ), ), diff --git a/lib/screens/orders_screen.dart b/lib/screens/orders_screen.dart new file mode 100644 index 0000000..fbfc715 --- /dev/null +++ b/lib/screens/orders_screen.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +/// Orders tab — placeholder screen for future ordering features. +class OrdersScreen extends StatelessWidget { + const OrdersScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(40), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: AppTheme.surfaceContainerLow, + shape: BoxShape.circle, + ), + child: Icon( + Icons.receipt_long_rounded, + size: 56, + color: AppTheme.secondary, + ), + ), + const SizedBox(height: 28), + Text( + 'Orders', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 12), + Text( + 'Online ordering is coming soon!\nStay tuned for updates.', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.onSurfaceVariant, + height: 1.6, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/promo_detail_screen.dart b/lib/screens/promo_detail_screen.dart new file mode 100644 index 0000000..dce8a69 --- /dev/null +++ b/lib/screens/promo_detail_screen.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; +import '../theme/app_theme.dart'; + +/// Promo detail screen — mirrors notification detail but for promo highlights. +/// Shows the promo image (full size), title, and rich HTML body content. +class PromoDetailScreen extends StatelessWidget { + final dynamic promo; + const PromoDetailScreen({super.key, required this.promo}); + + @override + Widget build(BuildContext context) { + final title = promo['name'] as String? ?? 'Promo'; + final bodyHtml = promo['body'] as String? ?? ''; + final base64Img = promo['image_128'] as String?; + + return Scaffold( + appBar: AppBar(title: Text(title)), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Promo image (full width) + if (base64Img != null && base64Img.isNotEmpty) + _buildPromoImage(base64Img), + + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + + if (bodyHtml.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Html( + data: bodyHtml, + style: { + 'body': Style( + color: AppTheme.onSurface, + fontFamily: 'Manrope', + fontSize: FontSize(15), + lineHeight: const LineHeight(1.6), + ), + 'p': Style(margin: Margins.only(bottom: 12)), + 'h1': Style( + color: AppTheme.onSurface, + fontSize: FontSize(22), + fontWeight: FontWeight.bold, + ), + 'h2': Style( + color: AppTheme.onSurface, + fontSize: FontSize(18), + fontWeight: FontWeight.bold, + ), + 'a': Style(color: AppTheme.secondary), + }, + ), + ) + else + Padding( + padding: const EdgeInsets.all(20), + child: Text( + 'No content available.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _buildPromoImage(String base64Img) { + try { + final Uint8List bytes = base64Decode(base64Img); + return Image.memory( + bytes, + width: double.infinity, + height: 220, + fit: BoxFit.cover, + ); + } catch (_) { + return Container( + height: 220, + color: AppTheme.surfaceContainer, + child: const Center( + child: Icon(Icons.local_offer_rounded, size: 56, color: AppTheme.outlineVariant), + ), + ); + } + } +} diff --git a/lib/services/odoo_service.dart b/lib/services/odoo_service.dart index 94ed2b3..c73be4b 100644 --- a/lib/services/odoo_service.dart +++ b/lib/services/odoo_service.dart @@ -1,5 +1,6 @@ import 'package:odoo_rpc/odoo_rpc.dart'; import 'config.dart'; +import 'theme_manager.dart'; class OdooService { static final OdooService _instance = OdooService._internal(); @@ -13,7 +14,6 @@ class OdooService { } /// Returns the session cookie header value for authenticated image loading. - /// Usage: Image.network(url, headers: {'Cookie': OdooService().sessionCookie}) String get sessionCookie { final sessionId = client?.sessionId?.id ?? ''; return 'session_id=$sessionId'; @@ -23,6 +23,14 @@ class OdooService { String notificationImageUrl(int notifId) => '${AppConfig.odooUrl}/web/image/mapan.app.notification/$notifId/image'; + /// Returns the URL for a carousel image (uploaded). + String carouselImageUrl(int slideId) => + '${AppConfig.odooUrl}/web/image/mapan.app.carousel/$slideId/image'; + + /// Returns the URL for a promo image (uploaded). + String promoImageUrl(int promoId) => + '${AppConfig.odooUrl}/web/image/mapan.app.promo/$promoId/image'; + Future login(String db, String username, String password) async { if (client == null) throw Exception("Connect to Odoo first"); return await client!.authenticate(db, username, password); @@ -30,17 +38,103 @@ class OdooService { Future> 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({ 'model': 'loyalty.card', 'method': 'search_read', 'args': [ - [['partner_id', '=', partnerId]], + [ + ['partner_id', '=', partnerId], + ['program_id.program_type', '=', 'loyalty'], + ], ], 'kwargs': {'fields': ['points', 'program_id', 'code']} }) as List; } + /// Fetch subscription cards only — displayed as a "My Subscriptions" list. + Future> getSubscriptionCards(int partnerId) async { + if (client == null) throw Exception("Connect to Odoo first"); + + return await client!.callKw({ + 'model': 'loyalty.card', + 'method': 'search_read', + 'args': [ + [ + ['partner_id', '=', partnerId], + ['program_id.program_type', '=', 'subscription'], + ], + ], + 'kwargs': { + 'fields': [ + 'program_id', + 'code', + 'subscription_start_date', + 'subscription_end_date', + ] + } + }) as List; + } + + /// Fetch carousel slides and promo highlights for the home screen. + Future> getCmsContent() async { + if (client == null) throw Exception("Connect to Odoo first"); + final response = await client!.callRPC( + '/api/loyalty/cms_content', + 'call', + {}, + ); + if (response != null && response['status'] == 'success') { + return response as Map; + } + return {'carousel': [], 'promos': []}; + } + + /// Fetch app configuration (About Us URL, Contact Us URL, Branding & Theme). + static Future> getAppConfig() async { + final tempClient = OdooClient(AppConfig.odooUrl); + try { + final res = await tempClient.callRPC('/api/loyalty/app_config', 'call', {}); + if (res != null && res['status'] == 'success') { + final configMap = { + 'about_us_url': (res['about_us_url'] as String?) ?? '', + 'contact_us_url': (res['contact_us_url'] as String?) ?? '', + 'brand_logo': (res['brand_logo'] as String?) ?? '', + 'primary_color': (res['primary_color'] as String?) ?? '#C62828', + 'secondary_color': (res['secondary_color'] as String?) ?? '#FF8F00', + }; + // Save and apply new branding and theme colors dynamically + await ThemeManager.instance.updateConfig( + primaryHex: configMap['primary_color']!, + secondaryHex: configMap['secondary_color']!, + brandLogoB64: configMap['brand_logo']!, + ); + return configMap; + } + return {'about_us_url': '', 'contact_us_url': ''}; + } catch (_) { + return {'about_us_url': '', 'contact_us_url': ''}; + } finally { + tempClient.close(); + } + } + + /// Fetch loyalty point history for the current user. + Future> getOrderHistory() async { + if (client == null) throw Exception("Connect to Odoo first"); + final response = await client!.callRPC( + '/api/loyalty/order_history', + 'call', + {}, + ); + if (response != null && response['status'] == 'success') { + return response['data'] as List; + } + return []; + } + Future sendOtp({ String? email, String? phone, @@ -134,15 +228,14 @@ class OdooService { ); } - /// Fetch public branch information using our secure Odoo endpoint - /// This completely isolates the Admin API Key from the Flutter Source Code! + /// Fetch public branch information (includes lat/lng for geolocation sorting). static Future> getPublicBranches() async { final tempClient = OdooClient(AppConfig.odooUrl); try { final res = await tempClient.callRPC( - '/api/loyalty/branches', - 'call', - {} + '/api/loyalty/branches', + 'call', + {} ); if (res != null && res['status'] == 'success') { return res['data'] as List; diff --git a/lib/services/theme_manager.dart b/lib/services/theme_manager.dart new file mode 100644 index 0000000..3b9b1a7 --- /dev/null +++ b/lib/services/theme_manager.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../theme/app_theme.dart'; + +/// Manages dynamic branding and app theme settings fetched from the Odoo backend. +/// Automatically handles offline caching via SharedPreferences. +class ThemeManager extends ChangeNotifier { + static final ThemeManager instance = ThemeManager._internal(); + + ThemeManager._internal(); + + Color _primaryColor = AppTheme.primary; + Color _secondaryColor = AppTheme.secondary; + String _brandLogo = ''; + + Color get primaryColor => _primaryColor; + Color get secondaryColor => _secondaryColor; + String get brandLogo => _brandLogo; + + ThemeData get themeData => AppTheme.getTheme( + primaryColor: _primaryColor, + secondaryColor: _secondaryColor, + ); + + /// Initialize cached settings on app launch + Future initialize() async { + final prefs = await SharedPreferences.getInstance(); + final primHex = prefs.getString('theme_primary_color'); + final secHex = prefs.getString('theme_secondary_color'); + _brandLogo = prefs.getString('theme_brand_logo') ?? ''; + + if (primHex != null) { + _primaryColor = _parseHexColor(primHex) ?? AppTheme.primary; + } + if (secHex != null) { + _secondaryColor = _parseHexColor(secHex) ?? AppTheme.secondary; + } + } + + /// Update theme options and persist them to SharedPreferences + Future updateConfig({ + required String primaryHex, + required String secondaryHex, + required String brandLogoB64, + }) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('theme_primary_color', primaryHex); + await prefs.setString('theme_secondary_color', secondaryHex); + await prefs.setString('theme_brand_logo', brandLogoB64); + + _primaryColor = _parseHexColor(primaryHex) ?? AppTheme.primary; + _secondaryColor = _parseHexColor(secondaryHex) ?? AppTheme.secondary; + _brandLogo = brandLogoB64; + notifyListeners(); + } + + Color? _parseHexColor(String hexString) { + try { + final buffer = StringBuffer(); + if (hexString.length == 6 || hexString.length == 7) buffer.write('ff'); + buffer.write(hexString.replaceFirst('#', '')); + return Color(int.parse(buffer.toString(), radix: 16)); + } catch (_) { + return null; + } + } +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index dda8e56..1e6514f 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -23,23 +23,31 @@ class AppTheme { static const Color onPrimary = Color(0xFFFFF59B); static const Color outlineVariant = Color(0xFFACADAB); - static ThemeData get lightTheme { + static ThemeData get lightTheme => getTheme(); + + static ThemeData getTheme({Color? primaryColor, Color? secondaryColor}) { final baseTheme = ThemeData.light(); + final pColor = primaryColor ?? primary; + final sColor = secondaryColor ?? secondary; + + // Dynamically compute readable contrast text colors + final onPrimaryColor = pColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; + final onSecondaryColor = sColor.computeLuminance() > 0.5 ? Colors.black : Colors.white; return ThemeData( useMaterial3: true, scaffoldBackgroundColor: surface, - colorScheme: const ColorScheme.light( - primary: primary, - primaryContainer: primaryContainer, - secondary: secondary, - secondaryContainer: secondaryContainer, - onSecondaryContainer: onSecondaryContainer, + colorScheme: ColorScheme.light( + primary: pColor, + primaryContainer: pColor, + secondary: sColor, + secondaryContainer: sColor, + onSecondaryContainer: onSecondaryColor, surface: surface, onSurface: onSurface, onSurfaceVariant: onSurfaceVariant, - onPrimary: onPrimary, - error: Color(0xFFB02500), + onPrimary: onPrimaryColor, + error: const Color(0xFFB02500), ), textTheme: baseTheme.textTheme.copyWith( displayLarge: GoogleFonts.epilogue( @@ -84,8 +92,8 @@ class AppTheme { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.zero, ), - foregroundColor: onPrimaryContainer, - backgroundColor: primaryContainer, + foregroundColor: onPrimaryColor, + backgroundColor: pColor, elevation: 0, side: const BorderSide(color: Colors.red, width: 2), ), @@ -109,7 +117,7 @@ class AppTheme { borderRadius: BorderRadius.zero, ), focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: primary.withOpacity(0.5), width: 2), + borderSide: BorderSide(color: pColor.withValues(alpha: 0.5), width: 2), borderRadius: BorderRadius.zero, ), labelStyle: const TextStyle(color: onSurfaceVariant), diff --git a/lib/widgets/carousel_widget.dart b/lib/widgets/carousel_widget.dart new file mode 100644 index 0000000..cc10e40 --- /dev/null +++ b/lib/widgets/carousel_widget.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:smooth_page_indicator/smooth_page_indicator.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../theme/app_theme.dart'; + +/// Auto-scrolling carousel widget that shows slides from CMS. +/// Each slide can have an uploaded image (base64) or external image URL. +class CarouselWidget extends StatefulWidget { + final List slides; + + const CarouselWidget({super.key, required this.slides}); + + @override + State createState() => _CarouselWidgetState(); +} + +class _CarouselWidgetState extends State { + final PageController _controller = PageController(); + int _current = 0; + + @override + void initState() { + super.initState(); + if (widget.slides.length > 1) { + Future.delayed(const Duration(seconds: 4), _autoScroll); + } + } + + void _autoScroll() { + if (!mounted) return; + final next = (_current + 1) % widget.slides.length; + _controller.animateToPage( + next, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + Future.delayed(const Duration(seconds: 4), _autoScroll); + } + + Future _onTap(dynamic slide) async { + final linkUrl = slide['link_url'] as String?; + if (linkUrl != null && linkUrl.isNotEmpty) { + final uri = Uri.tryParse(linkUrl); + if (uri != null && await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.slides.isEmpty) return const SizedBox.shrink(); + + return Column( + children: [ + SizedBox( + height: 180, + child: PageView.builder( + controller: _controller, + itemCount: widget.slides.length, + onPageChanged: (i) => setState(() => _current = i), + itemBuilder: (context, index) { + final slide = widget.slides[index]; + return GestureDetector( + onTap: () => _onTap(slide), + child: _SlideImage(slide: slide), + ); + }, + ), + ), + if (widget.slides.length > 1) + Padding( + padding: const EdgeInsets.only(top: 10), + child: SmoothPageIndicator( + controller: _controller, + count: widget.slides.length, + effect: ExpandingDotsEffect( + dotHeight: 6, + dotWidth: 6, + expansionFactor: 3, + dotColor: AppTheme.surfaceContainer, + activeDotColor: AppTheme.secondary, + ), + ), + ), + ], + ); + } +} + +class _SlideImage extends StatelessWidget { + final dynamic slide; + const _SlideImage({required this.slide}); + + @override + Widget build(BuildContext context) { + final base64Img = slide['image'] as String?; + final externalUrl = slide['image_url'] as String?; + + Widget image; + + if (base64Img != null && base64Img.isNotEmpty) { + // Uploaded image — decode base64 + try { + final Uint8List bytes = base64Decode(base64Img); + image = Image.memory(bytes, fit: BoxFit.cover, width: double.infinity); + } catch (_) { + image = _placeholder(); + } + } else if (externalUrl != null && externalUrl.isNotEmpty) { + // External URL image + image = Image.network( + externalUrl, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (_, __, ___) => _placeholder(), + loadingBuilder: (ctx, child, progress) { + if (progress == null) return child; + return const Center(child: CircularProgressIndicator()); + }, + ); + } else { + image = _placeholder(); + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: const BoxDecoration(color: AppTheme.surfaceContainerLow), + child: ClipRect(child: image), + ); + } + + Widget _placeholder() { + return Container( + color: AppTheme.surfaceContainer, + child: const Center( + child: Icon(Icons.image_rounded, size: 48, color: AppTheme.outlineVariant), + ), + ); + } +} diff --git a/lib/widgets/promo_card_widget.dart b/lib/widgets/promo_card_widget.dart new file mode 100644 index 0000000..fe79b0a --- /dev/null +++ b/lib/widgets/promo_card_widget.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../screens/promo_detail_screen.dart'; + +/// Horizontal scrollable row of promo highlight cards. +/// Tapping a card opens the full detail screen with rich text content. +class PromoCardRow extends StatelessWidget { + final List promos; + + const PromoCardRow({super.key, required this.promos}); + + @override + Widget build(BuildContext context) { + if (promos.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + 'Promo Highlights', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + SizedBox( + height: 180, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: promos.length, + itemBuilder: (context, index) { + final promo = promos[index]; + return _PromoCard(promo: promo); + }, + ), + ), + ], + ); + } +} + +class _PromoCard extends StatelessWidget { + final dynamic promo; + const _PromoCard({required this.promo}); + + @override + Widget build(BuildContext context) { + final base64Img = promo['image_128'] as String?; + final title = promo['name'] as String? ?? ''; + + Widget imageWidget; + if (base64Img != null && base64Img.isNotEmpty) { + try { + final Uint8List bytes = base64Decode(base64Img); + imageWidget = Image.memory(bytes, fit: BoxFit.cover, + width: double.infinity, height: 110); + } catch (_) { + imageWidget = _imagePlaceholder(); + } + } else { + imageWidget = _imagePlaceholder(); + } + + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => PromoDetailScreen(promo: promo)), + ); + }, + child: Container( + width: 140, + margin: const EdgeInsets.only(right: 12), + decoration: const BoxDecoration( + color: AppTheme.surfaceContainerLow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 110, + width: double.infinity, + child: ClipRect(child: imageWidget), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + title, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.onSurface, + fontWeight: FontWeight.bold, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } + + Widget _imagePlaceholder() { + return Container( + color: AppTheme.surfaceContainer, + child: const Center( + child: Icon(Icons.local_offer_rounded, size: 32, color: AppTheme.outlineVariant), + ), + ); + } +} diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart new file mode 100644 index 0000000..9f62a43 --- /dev/null +++ b/lib/widgets/subscription_list_widget.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; + +/// Compact "My Subscriptions" list — displayed on the home tab below the loyalty card. +/// Each row shows the subscription name, active/expired badge, validity period, and card code. +class SubscriptionListWidget extends StatelessWidget { + final List subscriptions; + + const SubscriptionListWidget({super.key, required this.subscriptions}); + + @override + Widget build(BuildContext context) { + if (subscriptions.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 10), + child: Text( + 'MY SUBSCRIPTIONS', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.onSurfaceVariant, + letterSpacing: 1.2, + fontSize: 11, + ), + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: const BoxDecoration( + color: AppTheme.surfaceContainerLow, + ), + child: Column( + children: List.generate(subscriptions.length, (index) { + final sub = subscriptions[index]; + final isLast = index == subscriptions.length - 1; + return _SubscriptionTile(sub: sub, isLast: isLast); + }), + ), + ), + ], + ); + } +} + +class _SubscriptionTile extends StatelessWidget { + final dynamic sub; + final bool isLast; + + const _SubscriptionTile({required this.sub, required this.isLast}); + + /// Determine active/expired status from subscription_end_date. + bool _isActive() { + final endRaw = sub['subscription_end_date']; + if (endRaw == null || endRaw == false) return true; // no end date = no expiry + try { + final endDate = DateTime.parse(endRaw.toString()); + return endDate.isAfter(DateTime.now()); + } catch (_) { + return true; + } + } + + /// Format a date string (YYYY-MM-DD) to a readable label. + String _formatDate(dynamic raw) { + if (raw == null || raw == false) return '—'; + try { + final dt = DateTime.parse(raw.toString()); + return '${dt.day.toString().padLeft(2, '0')} ' + '${_monthName(dt.month)} ' + '${dt.year}'; + } catch (_) { + return raw.toString(); + } + } + + String _monthName(int m) { + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ]; + return months[m - 1]; + } + + @override + Widget build(BuildContext context) { + final programName = sub['program_id'] is List + ? (sub['program_id'][1] as String? ?? 'Subscription') + : 'Subscription'; + final code = sub['code'] as String? ?? ''; + final startDate = _formatDate(sub['subscription_start_date']); + final endDate = _formatDate(sub['subscription_end_date']); + final active = _isActive(); + + return Container( + decoration: BoxDecoration( + border: isLast + ? null + : const Border( + bottom: BorderSide( + color: AppTheme.surfaceContainer, + width: 1, + ), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Icon + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: active + ? AppTheme.secondaryContainer.withValues(alpha: 0.35) + : AppTheme.surfaceContainer, + shape: BoxShape.rectangle, + ), + child: Icon( + Icons.verified_rounded, + size: 22, + color: active ? AppTheme.secondary : AppTheme.outlineVariant, + ), + ), + + const SizedBox(width: 14), + + // Name + dates + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + programName, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.onSurface, + ), + ), + const SizedBox(height: 3), + Text( + '$startDate → $endDate', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.onSurfaceVariant, + ), + ), + if (code.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + code, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.outlineVariant, + fontFamily: 'monospace', + fontSize: 11, + ), + ), + ], + ], + ), + ), + + const SizedBox(width: 8), + + // Active / Expired badge + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: active + ? const Color(0xFF1B5E20).withValues(alpha: 0.12) + : const Color(0xFFB02500).withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + active ? 'ACTIVE' : 'EXPIRED', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + color: active + ? const Color(0xFF2E7D32) + : const Color(0xFFB02500), + ), + ), + ), + ], + ), + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 3fc006e..c9296a3 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,12 +7,14 @@ import Foundation import app_badge_plus import flutter_local_notifications +import geolocator_apple import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppBadgePlusPlugin.register(with: registry.registrar(forPlugin: "AppBadgePlusPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index bd7dd1a..1bfe11f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -145,11 +153,27 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_html: + dependency: "direct main" + description: + name: flutter_html + sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d" + url: "https://pub.dev" + source: hosted + version: "3.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -208,6 +232,54 @@ packages: description: flutter source: sdk version: "0.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" + url: "https://pub.dev" + source: hosted + version: "2.3.14" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 + url: "https://pub.dev" + source: hosted + version: "4.2.8" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" glob: dependency: transitive description: @@ -232,6 +304,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -296,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + list_counter: + dependency: transitive + description: + name: list_counter + sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 + url: "https://pub.dev" + source: hosted + version: "1.0.2" logging: dependency: transitive description: @@ -557,6 +645,14 @@ packages: description: flutter source: sdk version: "0.0.0" + smooth_page_indicator: + dependency: "direct main" + description: + name: smooth_page_indicator + sha256: b21ebb8bc39cf72d11c7cfd809162a48c3800668ced1c9da3aade13a32cf6c1c + url: "https://pub.dev" + source: hosted + version: "1.2.1" source_span: dependency: transitive description: @@ -685,6 +781,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3eb0b5f..cff5579 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,9 @@ dependencies: url_launcher: ^6.3.2 app_badge_plus: ^1.3.1 permission_handler: ^12.0.3 + geolocator: ^13.0.2 + smooth_page_indicator: ^1.2.0 + flutter_html: ^3.0.0-beta.2 dev_dependencies: flutter_test: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index a0d0bbe..ce843bc 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ef51301..75c3fc1 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + geolocator_windows permission_handler_windows url_launcher_windows )