feat: implement dynamic theming, app shell navigation, and expanded Odoo API services for CMS, orders, and subscriptions.
This commit is contained in:
parent
f0c2942861
commit
c32589aba4
File diff suppressed because one or more lines are too long
@ -2,6 +2,8 @@
|
|||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="Mie Mapan"
|
android:label="Mie Mapan"
|
||||||
|
|||||||
@ -5,12 +5,13 @@ import 'package:workmanager/workmanager.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:odoo_rpc/odoo_rpc.dart';
|
import 'package:odoo_rpc/odoo_rpc.dart';
|
||||||
import 'screens/login_screen.dart';
|
import 'screens/login_screen.dart';
|
||||||
import 'screens/loyalty_dashboard.dart';
|
import 'screens/main_shell.dart';
|
||||||
import 'services/odoo_service.dart';
|
import 'services/odoo_service.dart';
|
||||||
import 'services/config.dart';
|
import 'services/config.dart';
|
||||||
import 'services/background_service.dart';
|
import 'services/background_service.dart';
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'theme/app_theme.dart';
|
|
||||||
|
import 'services/theme_manager.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@ -20,6 +21,9 @@ void main() async {
|
|||||||
await notifService.initialize();
|
await notifService.initialize();
|
||||||
await notifService.requestPermission();
|
await notifService.requestPermission();
|
||||||
|
|
||||||
|
// Initialize cached theme configuration
|
||||||
|
await ThemeManager.instance.initialize();
|
||||||
|
|
||||||
if (Platform.isAndroid || Platform.isIOS) {
|
if (Platform.isAndroid || Platform.isIOS) {
|
||||||
Workmanager().initialize(
|
Workmanager().initialize(
|
||||||
callbackDispatcher,
|
callbackDispatcher,
|
||||||
@ -43,7 +47,7 @@ void main() async {
|
|||||||
final session = OdooSession.fromJson(sessionMap);
|
final session = OdooSession.fromJson(sessionMap);
|
||||||
final service = OdooService();
|
final service = OdooService();
|
||||||
service.connect(AppConfig.odooUrl, session: session);
|
service.connect(AppConfig.odooUrl, session: session);
|
||||||
homeWidget = LoyaltyDashboard(partnerId: session.partnerId);
|
homeWidget = MainShell(partnerId: session.partnerId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
homeWidget = const LoginScreen();
|
homeWidget = const LoginScreen();
|
||||||
}
|
}
|
||||||
@ -58,11 +62,16 @@ class OdooLoyaltyApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return ListenableBuilder(
|
||||||
title: 'Mie Mapan Loyalty App',
|
listenable: ThemeManager.instance,
|
||||||
debugShowCheckedModeBanner: false,
|
builder: (context, _) {
|
||||||
theme: AppTheme.lightTheme,
|
return MaterialApp(
|
||||||
home: homeWidget,
|
title: 'Mie Mapan Loyalty App',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: ThemeManager.instance.themeData,
|
||||||
|
home: homeWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
354
lib/screens/account_screen.dart
Normal file
354
lib/screens/account_screen.dart
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../services/odoo_service.dart';
|
||||||
|
import '../services/notification_service.dart';
|
||||||
|
import '../theme/app_theme.dart';
|
||||||
|
import '../widgets/agreement_dialog.dart';
|
||||||
|
import 'login_screen.dart';
|
||||||
|
|
||||||
|
class AccountScreen extends StatefulWidget {
|
||||||
|
const AccountScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AccountScreen> createState() => _AccountScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccountScreenState extends State<AccountScreen> {
|
||||||
|
final _phraseController = TextEditingController();
|
||||||
|
final _passwordController = TextEditingController();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
String _aboutUsUrl = '';
|
||||||
|
String _contactUsUrl = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadAppConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAppConfig() async {
|
||||||
|
try {
|
||||||
|
final config = await OdooService.getAppConfig();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_aboutUsUrl = config['about_us_url'] ?? '';
|
||||||
|
_contactUsUrl = config['contact_us_url'] ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,6 @@
|
|||||||
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../services/odoo_service.dart';
|
import '../services/odoo_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
@ -13,37 +15,121 @@ class BranchesScreen extends StatefulWidget {
|
|||||||
class _BranchesScreenState extends State<BranchesScreen> {
|
class _BranchesScreenState extends State<BranchesScreen> {
|
||||||
List<dynamic> _branches = [];
|
List<dynamic> _branches = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
Position? _userPosition;
|
||||||
|
bool _locationDenied = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_fetchBranches();
|
_fetchBranchesWithLocation();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchBranches() async {
|
Future<void> _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 {
|
try {
|
||||||
final branches = await OdooService.getPublicBranches();
|
final branches = await OdooService.getPublicBranches();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_branches = branches;
|
_userPosition = pos;
|
||||||
|
_locationDenied = pos == null;
|
||||||
|
|
||||||
|
if (pos != null) {
|
||||||
|
// Sort branches by distance from user
|
||||||
|
_branches = List<dynamic>.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<dynamic>.from(branches)
|
||||||
|
..sort((a, b) => (a['name'] as String? ?? '')
|
||||||
|
.compareTo(b['name'] as String? ?? ''));
|
||||||
|
}
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isLoading = false);
|
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<void> _launchMaps(String queryTerm) async {
|
Future<void> _launchMaps(String queryTerm) async {
|
||||||
final query = Uri.encodeComponent(queryTerm);
|
final query = Uri.encodeComponent(queryTerm);
|
||||||
final url = Uri.parse('https://www.google.com/maps/search/?api=1&query=$query');
|
final url = Uri.parse('https://www.google.com/maps/search/?api=1&query=$query');
|
||||||
if (await canLaunchUrl(url)) {
|
if (await canLaunchUrl(url)) {
|
||||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
} else {
|
} 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<BranchesScreen> {
|
|||||||
if (await canLaunchUrl(url)) {
|
if (await canLaunchUrl(url)) {
|
||||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
} else {
|
} 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return _isLoading
|
||||||
appBar: AppBar(title: const Text('Find Branch')),
|
? const Center(child: CircularProgressIndicator())
|
||||||
body: _isLoading
|
: RefreshIndicator(
|
||||||
? const Center(child: CircularProgressIndicator())
|
onRefresh: _fetchBranchesWithLocation,
|
||||||
: _branches.isEmpty
|
child: Column(
|
||||||
? const Center(child: Text('No branches available.', style: TextStyle(fontSize: 16)))
|
children: [
|
||||||
: ListView.builder(
|
// Location status banner
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
if (_locationDenied)
|
||||||
itemCount: _branches.length,
|
Container(
|
||||||
itemBuilder: (context, index) {
|
width: double.infinity,
|
||||||
final branch = _branches[index];
|
color: AppTheme.surfaceContainerLow,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
final street = branch['street'] != null && branch['street'] != false ? branch['street'] : '';
|
child: Row(
|
||||||
final city = branch['city'] != null && branch['city'] != false ? branch['city'] : '';
|
children: [
|
||||||
final phone = branch['phone'] != null && branch['phone'] != false ? branch['phone'] : '';
|
const Icon(Icons.location_off, size: 16, color: AppTheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 8),
|
||||||
final addressParts = [street, city].where((e) => e.toString().isNotEmpty).join(', ');
|
Expanded(
|
||||||
|
child: Text(
|
||||||
return Container(
|
'Location not available. Showing branches alphabetically.',
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
decoration: const BoxDecoration(
|
color: AppTheme.onSurfaceVariant,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
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: [
|
children: [
|
||||||
const Icon(Icons.phone, size: 14, color: AppTheme.onSurfaceVariant),
|
Text(
|
||||||
const SizedBox(width: 4),
|
addressParts.isEmpty
|
||||||
Text(phone, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: AppTheme.onSurfaceVariant)),
|
? '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,
|
||||||
trailing: phone.isNotEmpty
|
color: AppTheme.onSurface),
|
||||||
? IconButton(
|
onPressed: () => _launchWhatsApp(phone),
|
||||||
icon: const Icon(Icons.chat_bubble, color: AppTheme.onSurface),
|
tooltip: 'Chat on WhatsApp',
|
||||||
onPressed: () => _launchWhatsApp(phone),
|
)
|
||||||
tooltip: 'Chat on WhatsApp',
|
: const Icon(Icons.chevron_right,
|
||||||
)
|
color: AppTheme.onSurfaceVariant),
|
||||||
: const Icon(Icons.chevron_right, color: AppTheme.onSurfaceVariant),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import 'package:odoo_rpc/odoo_rpc.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../services/odoo_service.dart';
|
import '../services/odoo_service.dart';
|
||||||
import '../services/config.dart';
|
import '../services/config.dart';
|
||||||
import 'loyalty_dashboard.dart';
|
import 'main_shell.dart';
|
||||||
import 'branches_screen.dart';
|
import 'branches_screen.dart';
|
||||||
import 'activation_screen.dart';
|
import 'activation_screen.dart';
|
||||||
import 'signup_screen.dart';
|
import 'signup_screen.dart';
|
||||||
@ -43,7 +43,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
|||||||
Navigator.pushReplacement(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => LoyaltyDashboard(partnerId: session.partnerId),
|
builder: (_) => MainShell(partnerId: session.partnerId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import '../services/odoo_service.dart';
|
import '../services/odoo_service.dart';
|
||||||
import '../services/notification_service.dart';
|
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'notifications_screen.dart';
|
import '../widgets/carousel_widget.dart';
|
||||||
import 'branches_screen.dart';
|
import '../widgets/promo_card_widget.dart';
|
||||||
import 'settings_screen.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 {
|
class LoyaltyDashboard extends StatefulWidget {
|
||||||
final int partnerId;
|
final int partnerId;
|
||||||
const LoyaltyDashboard({super.key, required this.partnerId});
|
const LoyaltyDashboard({super.key, required this.partnerId});
|
||||||
@ -18,249 +17,166 @@ class LoyaltyDashboard extends StatefulWidget {
|
|||||||
|
|
||||||
class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
||||||
List<dynamic> _loyaltyCards = [];
|
List<dynamic> _loyaltyCards = [];
|
||||||
|
List<dynamic> _subscriptions = [];
|
||||||
|
List<dynamic> _carouselSlides = [];
|
||||||
|
List<dynamic> _promos = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
int _unreadNotificationCount = 0;
|
|
||||||
Timer? _notificationTimer;
|
|
||||||
|
|
||||||
// Shared pref keys
|
|
||||||
static const _kLastNotified = 'last_device_notified_id';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_fetchLoyaltyData();
|
_fetchAll();
|
||||||
_fetchNotificationCount();
|
|
||||||
_notificationTimer = Timer.periodic(const Duration(seconds: 10), (_) {
|
|
||||||
_fetchNotificationCount();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<void> _fetchAll() async {
|
||||||
void dispose() {
|
setState(() => _isLoading = true);
|
||||||
_notificationTimer?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fetchNotificationCount() async {
|
|
||||||
try {
|
try {
|
||||||
final client = OdooService().client;
|
final results = await Future.wait([
|
||||||
if (client == null) return;
|
OdooService().getLoyaltyCards(widget.partnerId),
|
||||||
|
OdooService().getSubscriptionCards(widget.partnerId),
|
||||||
|
OdooService().getCmsContent(),
|
||||||
|
]);
|
||||||
|
|
||||||
final response = await client.callRPC(
|
final cards = results[0] as List<dynamic>;
|
||||||
'/api/loyalty/fetch_notifications',
|
final subs = results[1] as List<dynamic>;
|
||||||
'call',
|
final cms = results[2] as Map<String, dynamic>;
|
||||||
{'last_id': 0},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response != null && response['status'] == 'success') {
|
if (mounted) {
|
||||||
final List<dynamic> notifs = response['data'] ?? [];
|
setState(() {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
_loyaltyCards = cards;
|
||||||
final lastNotifiedId = prefs.getInt(_kLastNotified) ?? 0;
|
_subscriptions = subs;
|
||||||
|
_carouselSlides = (cms['carousel'] as List<dynamic>?) ?? [];
|
||||||
// Check read list
|
_promos = (cms['promos'] as List<dynamic>?) ?? [];
|
||||||
final readIds = prefs.getStringList('read_notification_ids');
|
_isLoading = false;
|
||||||
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<dynamic> 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _fetchLoyaltyData() async {
|
|
||||||
try {
|
|
||||||
final cards = await OdooService().getLoyaltyCards(widget.partnerId);
|
|
||||||
await _fetchNotificationCount();
|
|
||||||
setState(() {
|
|
||||||
_loyaltyCards = cards;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isLoading = false);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
if (_isLoading) {
|
||||||
appBar: AppBar(
|
return const Center(child: CircularProgressIndicator());
|
||||||
title: const Text('My Rewards'),
|
}
|
||||||
actions: [
|
|
||||||
IconButton(
|
return RefreshIndicator(
|
||||||
icon: const Icon(Icons.storefront),
|
onRefresh: _fetchAll,
|
||||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const BranchesScreen())),
|
child: ListView(
|
||||||
),
|
children: [
|
||||||
Stack(
|
// ── Loyalty Card ─────────────────────────────────────────────
|
||||||
clipBehavior: Clip.none,
|
if (_loyaltyCards.isEmpty)
|
||||||
children: [
|
Padding(
|
||||||
IconButton(
|
padding: const EdgeInsets.fromLTRB(24, 32, 24, 0),
|
||||||
icon: const Icon(Icons.notifications),
|
child: Text(
|
||||||
onPressed: () async {
|
'No active loyalty card yet.',
|
||||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const NotificationsScreen()));
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
_fetchNotificationCount();
|
textAlign: TextAlign.center,
|
||||||
},
|
|
||||||
),
|
),
|
||||||
if (_unreadNotificationCount > 0)
|
)
|
||||||
Positioned(
|
else
|
||||||
right: 8,
|
..._loyaltyCards.map((card) => _LoyaltyCardTile(card: card)),
|
||||||
top: 8,
|
|
||||||
child: Container(
|
// ── Subscriptions ─────────────────────────────────────────────
|
||||||
padding: const EdgeInsets.all(4),
|
if (_subscriptions.isNotEmpty)
|
||||||
decoration: const BoxDecoration(
|
SubscriptionListWidget(subscriptions: _subscriptions),
|
||||||
color: Colors.red,
|
|
||||||
shape: BoxShape.circle,
|
const SizedBox(height: 20),
|
||||||
),
|
|
||||||
constraints: const BoxConstraints(
|
// ── Carousel ──────────────────────────────────────────────────
|
||||||
minWidth: 16,
|
if (_carouselSlides.isNotEmpty) ...[
|
||||||
minHeight: 16,
|
CarouselWidget(slides: _carouselSlides),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
child: Text(
|
],
|
||||||
'$_unreadNotificationCount',
|
|
||||||
style: const TextStyle(
|
// ── Promo Highlights ─────────────────────────────────────────
|
||||||
color: Colors.white,
|
if (_promos.isNotEmpty) ...[
|
||||||
fontSize: 10,
|
PromoCardRow(promos: _promos),
|
||||||
fontWeight: FontWeight.bold,
|
const SizedBox(height: 24),
|
||||||
),
|
],
|
||||||
textAlign: TextAlign.center,
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
}
|
||||||
],
|
}
|
||||||
),
|
|
||||||
IconButton(
|
class _LoyaltyCardTile extends StatelessWidget {
|
||||||
icon: const Icon(Icons.settings),
|
final dynamic card;
|
||||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())),
|
const _LoyaltyCardTile({required this.card});
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
@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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
204
lib/screens/main_shell.dart
Normal file
204
lib/screens/main_shell.dart
Normal file
@ -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<MainShell> createState() => _MainShellState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainShellState extends State<MainShell> {
|
||||||
|
int _currentIndex = 0;
|
||||||
|
int _unreadNotificationCount = 0;
|
||||||
|
Timer? _notificationTimer;
|
||||||
|
|
||||||
|
static const _kLastNotified = 'last_device_notified_id';
|
||||||
|
|
||||||
|
late final List<Widget> _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<void> _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<dynamic> 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<dynamic> 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],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_html/flutter_html.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../services/odoo_service.dart';
|
import '../services/odoo_service.dart';
|
||||||
|
|
||||||
@ -10,20 +11,19 @@ class NotificationDetailScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final title = notif['title'] as String? ?? 'Notice';
|
final title = notif['title'] as String? ?? 'Notice';
|
||||||
final body = notif['body'] as String? ?? '';
|
final bodyHtml = notif['body'] as String? ?? '';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Notification Detail'),
|
title: const Text('Notification Detail'),
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(24.0),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// Header icon block
|
// Header block
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24),
|
||||||
color: AppTheme.surfaceContainerLow,
|
color: AppTheme.surfaceContainerLow,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@ -40,89 +40,115 @@ class NotificationDetailScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Padding(
|
Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
title,
|
||||||
child: Text(
|
textAlign: TextAlign.center,
|
||||||
title,
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
textAlign: TextAlign.center,
|
color: AppTheme.onSurface,
|
||||||
style:
|
fontSize: 20,
|
||||||
Theme.of(context).textTheme.headlineMedium?.copyWith(
|
),
|
||||||
color: AppTheme.onSurface,
|
|
||||||
fontSize: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Full image (authenticated)
|
||||||
if (notif['has_image'] == true) ...[
|
if (notif['has_image'] == true) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 16),
|
||||||
ClipRRect(
|
Padding(
|
||||||
borderRadius: BorderRadius.circular(8),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: Image.network(
|
child: ClipRect(
|
||||||
OdooService().notificationImageUrl(notif['id'] as int),
|
child: Image.network(
|
||||||
headers: {
|
OdooService().notificationImageUrl(notif['id'] as int),
|
||||||
'Cookie': OdooService().sessionCookie,
|
headers: {'Cookie': OdooService().sessionCookie},
|
||||||
},
|
fit: BoxFit.cover,
|
||||||
fit: BoxFit.cover,
|
loadingBuilder: (context, child, loadingProgress) {
|
||||||
loadingBuilder: (context, child, loadingProgress) {
|
if (loadingProgress == null) return child;
|
||||||
if (loadingProgress == null) return child;
|
return Container(
|
||||||
return Container(
|
height: 200,
|
||||||
height: 200,
|
color: AppTheme.surfaceContainerLow,
|
||||||
color: AppTheme.surfaceContainerLow,
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
);
|
||||||
);
|
},
|
||||||
},
|
errorBuilder: (context, error, stackTrace) {
|
||||||
errorBuilder: (context, error, stackTrace) {
|
return Container(
|
||||||
return Container(
|
height: 150,
|
||||||
height: 150,
|
color: AppTheme.surfaceContainerLow,
|
||||||
color: AppTheme.surfaceContainerLow,
|
child: const Center(
|
||||||
child: const Center(
|
child: Icon(Icons.broken_image_outlined,
|
||||||
child: Icon(Icons.broken_image_outlined, size: 48, color: AppTheme.onSurfaceVariant),
|
size: 48, color: AppTheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// Body label
|
// Message label
|
||||||
Text(
|
Padding(
|
||||||
'Message',
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
child: Text(
|
||||||
color: AppTheme.onSurfaceVariant,
|
'MESSAGE',
|
||||||
letterSpacing: 1.2,
|
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),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Divider line (editorial style)
|
// Rich HTML body
|
||||||
const Divider(
|
bodyHtml.isEmpty
|
||||||
color: AppTheme.surfaceContainerHighest,
|
? Padding(
|
||||||
thickness: 2,
|
padding: const EdgeInsets.all(24),
|
||||||
height: 2,
|
child: Text(
|
||||||
),
|
'No additional details.',
|
||||||
const SizedBox(height: 16),
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: AppTheme.onSurfaceVariant,
|
||||||
// Body text
|
fontStyle: FontStyle.italic,
|
||||||
body.isEmpty
|
),
|
||||||
? Text(
|
),
|
||||||
'No additional details.',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: AppTheme.onSurfaceVariant,
|
|
||||||
fontStyle: FontStyle.italic,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: Text(
|
: Padding(
|
||||||
body,
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
child: Html(
|
||||||
|
data: bodyHtml,
|
||||||
|
style: {
|
||||||
|
'body': Style(
|
||||||
color: AppTheme.onSurface,
|
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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
47
lib/screens/orders_screen.dart
Normal file
47
lib/screens/orders_screen.dart
Normal file
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
lib/screens/promo_detail_screen.dart
Normal file
99
lib/screens/promo_detail_screen.dart
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import 'package:odoo_rpc/odoo_rpc.dart';
|
import 'package:odoo_rpc/odoo_rpc.dart';
|
||||||
import 'config.dart';
|
import 'config.dart';
|
||||||
|
import 'theme_manager.dart';
|
||||||
|
|
||||||
class OdooService {
|
class OdooService {
|
||||||
static final OdooService _instance = OdooService._internal();
|
static final OdooService _instance = OdooService._internal();
|
||||||
@ -13,7 +14,6 @@ class OdooService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the session cookie header value for authenticated image loading.
|
/// Returns the session cookie header value for authenticated image loading.
|
||||||
/// Usage: Image.network(url, headers: {'Cookie': OdooService().sessionCookie})
|
|
||||||
String get sessionCookie {
|
String get sessionCookie {
|
||||||
final sessionId = client?.sessionId?.id ?? '';
|
final sessionId = client?.sessionId?.id ?? '';
|
||||||
return 'session_id=$sessionId';
|
return 'session_id=$sessionId';
|
||||||
@ -23,6 +23,14 @@ class OdooService {
|
|||||||
String notificationImageUrl(int notifId) =>
|
String notificationImageUrl(int notifId) =>
|
||||||
'${AppConfig.odooUrl}/web/image/mapan.app.notification/$notifId/image';
|
'${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<OdooSession> login(String db, String username, String password) async {
|
Future<OdooSession> login(String db, String username, String password) async {
|
||||||
if (client == null) throw Exception("Connect to Odoo first");
|
if (client == null) throw Exception("Connect to Odoo first");
|
||||||
return await client!.authenticate(db, username, password);
|
return await client!.authenticate(db, username, password);
|
||||||
@ -30,17 +38,103 @@ class OdooService {
|
|||||||
|
|
||||||
Future<List<dynamic>> getLoyaltyCards(int partnerId) async {
|
Future<List<dynamic>> getLoyaltyCards(int partnerId) async {
|
||||||
if (client == null) throw Exception("Connect to Odoo first");
|
if (client == null) throw Exception("Connect to Odoo first");
|
||||||
|
|
||||||
|
// Only fetch cards from 'loyalty' type programs (multi-tier: Silver/Gold/Platinum).
|
||||||
|
// Excludes subscriptions, coupons, gift cards, promotions, eWallets, etc.
|
||||||
return await client!.callKw({
|
return await client!.callKw({
|
||||||
'model': 'loyalty.card',
|
'model': 'loyalty.card',
|
||||||
'method': 'search_read',
|
'method': 'search_read',
|
||||||
'args': [
|
'args': [
|
||||||
[['partner_id', '=', partnerId]],
|
[
|
||||||
|
['partner_id', '=', partnerId],
|
||||||
|
['program_id.program_type', '=', 'loyalty'],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
'kwargs': {'fields': ['points', 'program_id', 'code']}
|
'kwargs': {'fields': ['points', 'program_id', 'code']}
|
||||||
}) as List<dynamic>;
|
}) as List<dynamic>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch subscription cards only — displayed as a "My Subscriptions" list.
|
||||||
|
Future<List<dynamic>> getSubscriptionCards(int partnerId) async {
|
||||||
|
if (client == null) throw Exception("Connect to Odoo first");
|
||||||
|
|
||||||
|
return await client!.callKw({
|
||||||
|
'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<dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch carousel slides and promo highlights for the home screen.
|
||||||
|
Future<Map<String, dynamic>> getCmsContent() async {
|
||||||
|
if (client == null) throw Exception("Connect to Odoo first");
|
||||||
|
final response = await client!.callRPC(
|
||||||
|
'/api/loyalty/cms_content',
|
||||||
|
'call',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
if (response != null && response['status'] == 'success') {
|
||||||
|
return response as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
return {'carousel': [], 'promos': []};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch app configuration (About Us URL, Contact Us URL, Branding & Theme).
|
||||||
|
static Future<Map<String, String>> 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<List<dynamic>> 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<dynamic>;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
Future<dynamic> sendOtp({
|
Future<dynamic> sendOtp({
|
||||||
String? email,
|
String? email,
|
||||||
String? phone,
|
String? phone,
|
||||||
@ -134,15 +228,14 @@ class OdooService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch public branch information using our secure Odoo endpoint
|
/// Fetch public branch information (includes lat/lng for geolocation sorting).
|
||||||
/// This completely isolates the Admin API Key from the Flutter Source Code!
|
|
||||||
static Future<List<dynamic>> getPublicBranches() async {
|
static Future<List<dynamic>> getPublicBranches() async {
|
||||||
final tempClient = OdooClient(AppConfig.odooUrl);
|
final tempClient = OdooClient(AppConfig.odooUrl);
|
||||||
try {
|
try {
|
||||||
final res = await tempClient.callRPC(
|
final res = await tempClient.callRPC(
|
||||||
'/api/loyalty/branches',
|
'/api/loyalty/branches',
|
||||||
'call',
|
'call',
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
if (res != null && res['status'] == 'success') {
|
if (res != null && res['status'] == 'success') {
|
||||||
return res['data'] as List<dynamic>;
|
return res['data'] as List<dynamic>;
|
||||||
|
|||||||
67
lib/services/theme_manager.dart
Normal file
67
lib/services/theme_manager.dart
Normal file
@ -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<void> 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<void> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,23 +23,31 @@ class AppTheme {
|
|||||||
static const Color onPrimary = Color(0xFFFFF59B);
|
static const Color onPrimary = Color(0xFFFFF59B);
|
||||||
static const Color outlineVariant = Color(0xFFACADAB);
|
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 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(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
scaffoldBackgroundColor: surface,
|
scaffoldBackgroundColor: surface,
|
||||||
colorScheme: const ColorScheme.light(
|
colorScheme: ColorScheme.light(
|
||||||
primary: primary,
|
primary: pColor,
|
||||||
primaryContainer: primaryContainer,
|
primaryContainer: pColor,
|
||||||
secondary: secondary,
|
secondary: sColor,
|
||||||
secondaryContainer: secondaryContainer,
|
secondaryContainer: sColor,
|
||||||
onSecondaryContainer: onSecondaryContainer,
|
onSecondaryContainer: onSecondaryColor,
|
||||||
surface: surface,
|
surface: surface,
|
||||||
onSurface: onSurface,
|
onSurface: onSurface,
|
||||||
onSurfaceVariant: onSurfaceVariant,
|
onSurfaceVariant: onSurfaceVariant,
|
||||||
onPrimary: onPrimary,
|
onPrimary: onPrimaryColor,
|
||||||
error: Color(0xFFB02500),
|
error: const Color(0xFFB02500),
|
||||||
),
|
),
|
||||||
textTheme: baseTheme.textTheme.copyWith(
|
textTheme: baseTheme.textTheme.copyWith(
|
||||||
displayLarge: GoogleFonts.epilogue(
|
displayLarge: GoogleFonts.epilogue(
|
||||||
@ -84,8 +92,8 @@ class AppTheme {
|
|||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.zero,
|
borderRadius: BorderRadius.zero,
|
||||||
),
|
),
|
||||||
foregroundColor: onPrimaryContainer,
|
foregroundColor: onPrimaryColor,
|
||||||
backgroundColor: primaryContainer,
|
backgroundColor: pColor,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
side: const BorderSide(color: Colors.red, width: 2),
|
side: const BorderSide(color: Colors.red, width: 2),
|
||||||
),
|
),
|
||||||
@ -109,7 +117,7 @@ class AppTheme {
|
|||||||
borderRadius: BorderRadius.zero,
|
borderRadius: BorderRadius.zero,
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderSide: BorderSide(color: primary.withOpacity(0.5), width: 2),
|
borderSide: BorderSide(color: pColor.withValues(alpha: 0.5), width: 2),
|
||||||
borderRadius: BorderRadius.zero,
|
borderRadius: BorderRadius.zero,
|
||||||
),
|
),
|
||||||
labelStyle: const TextStyle(color: onSurfaceVariant),
|
labelStyle: const TextStyle(color: onSurfaceVariant),
|
||||||
|
|||||||
149
lib/widgets/carousel_widget.dart
Normal file
149
lib/widgets/carousel_widget.dart
Normal file
@ -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<dynamic> slides;
|
||||||
|
|
||||||
|
const CarouselWidget({super.key, required this.slides});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CarouselWidget> createState() => _CarouselWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CarouselWidgetState extends State<CarouselWidget> {
|
||||||
|
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<void> _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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
114
lib/widgets/promo_card_widget.dart
Normal file
114
lib/widgets/promo_card_widget.dart
Normal file
@ -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<dynamic> 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
190
lib/widgets/subscription_list_widget.dart
Normal file
190
lib/widgets/subscription_list_widget.dart
Normal file
@ -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<dynamic> 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,12 +7,14 @@ import Foundation
|
|||||||
|
|
||||||
import app_badge_plus
|
import app_badge_plus
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
|
import geolocator_apple
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
AppBadgePlusPlugin.register(with: registry.registrar(forPlugin: "AppBadgePlusPlugin"))
|
AppBadgePlusPlugin.register(with: registry.registrar(forPlugin: "AppBadgePlusPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
104
pubspec.lock
104
pubspec.lock
@ -105,6 +105,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
csslib:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: csslib
|
||||||
|
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.2"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -145,11 +153,27 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@ -208,6 +232,54 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -232,6 +304,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.0.2"
|
||||||
|
html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.6"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -296,6 +376,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
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:
|
logging:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -557,6 +645,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
source_span:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -685,6 +781,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.5"
|
version: "3.1.5"
|
||||||
|
uuid:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.5.3"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -42,6 +42,9 @@ dependencies:
|
|||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
app_badge_plus: ^1.3.1
|
app_badge_plus: ^1.3.1
|
||||||
permission_handler: ^12.0.3
|
permission_handler: ^12.0.3
|
||||||
|
geolocator: ^13.0.2
|
||||||
|
smooth_page_indicator: ^1.2.0
|
||||||
|
flutter_html: ^3.0.0-beta.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
GeolocatorWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
geolocator_windows
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user