feat: implement dynamic theming, app shell navigation, and expanded Odoo API services for CMS, orders, and subscriptions.

This commit is contained in:
Suherdy Yacob 2026-06-14 09:21:23 +07:00
parent f0c2942861
commit c32589aba4
22 changed files with 1985 additions and 400 deletions

File diff suppressed because one or more lines are too long

View File

@ -2,6 +2,8 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<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
android:label="Mie Mapan"

View File

@ -5,12 +5,13 @@ import 'package:workmanager/workmanager.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:odoo_rpc/odoo_rpc.dart';
import 'screens/login_screen.dart';
import 'screens/loyalty_dashboard.dart';
import 'screens/main_shell.dart';
import 'services/odoo_service.dart';
import 'services/config.dart';
import 'services/background_service.dart';
import 'services/notification_service.dart';
import 'theme/app_theme.dart';
import 'services/theme_manager.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -20,6 +21,9 @@ void main() async {
await notifService.initialize();
await notifService.requestPermission();
// Initialize cached theme configuration
await ThemeManager.instance.initialize();
if (Platform.isAndroid || Platform.isIOS) {
Workmanager().initialize(
callbackDispatcher,
@ -43,7 +47,7 @@ void main() async {
final session = OdooSession.fromJson(sessionMap);
final service = OdooService();
service.connect(AppConfig.odooUrl, session: session);
homeWidget = LoyaltyDashboard(partnerId: session.partnerId);
homeWidget = MainShell(partnerId: session.partnerId);
} catch (e) {
homeWidget = const LoginScreen();
}
@ -58,11 +62,16 @@ class OdooLoyaltyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Mie Mapan Loyalty App',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
home: homeWidget,
return ListenableBuilder(
listenable: ThemeManager.instance,
builder: (context, _) {
return MaterialApp(
title: 'Mie Mapan Loyalty App',
debugShowCheckedModeBanner: false,
theme: ThemeManager.instance.themeData,
home: homeWidget,
);
},
);
}
}

View 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),
],
),
),
),
);
}
}

View File

@ -1,4 +1,6 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/odoo_service.dart';
import '../theme/app_theme.dart';
@ -13,37 +15,121 @@ class BranchesScreen extends StatefulWidget {
class _BranchesScreenState extends State<BranchesScreen> {
List<dynamic> _branches = [];
bool _isLoading = true;
Position? _userPosition;
bool _locationDenied = false;
@override
void 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 {
final branches = await OdooService.getPublicBranches();
if (mounted) {
setState(() {
_branches = branches;
_userPosition = pos;
_locationDenied = pos == null;
if (pos != null) {
// Sort branches by distance from user
_branches = List<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;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading branches. Check connection.')));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error loading branches. Check connection.')),
);
}
}
}
/// Haversine formula returns distance in kilometres.
double _distanceTo(Position pos, dynamic branch) {
final lat = _toDouble(branch['partner_latitude']);
final lng = _toDouble(branch['partner_longitude']);
if (lat == 0.0 && lng == 0.0) return double.maxFinite;
const R = 6371.0;
final dLat = _degToRad(lat - pos.latitude);
final dLng = _degToRad(lng - pos.longitude);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(_degToRad(pos.latitude)) *
cos(_degToRad(lat)) *
sin(dLng / 2) *
sin(dLng / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
double _degToRad(double deg) => deg * (pi / 180);
double _toDouble(dynamic val) {
if (val == null || val == false) return 0.0;
if (val is num) return val.toDouble();
return double.tryParse(val.toString()) ?? 0.0;
}
String _formatDistance(double km) {
if (km == double.maxFinite) return '';
if (km < 1) return '${(km * 1000).toStringAsFixed(0)} m';
return '${km.toStringAsFixed(1)} km';
}
Future<void> _launchMaps(String queryTerm) async {
final query = Uri.encodeComponent(queryTerm);
final url = Uri.parse('https://www.google.com/maps/search/?api=1&query=$query');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not open map.')));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open map.')),
);
}
}
}
@ -57,85 +143,193 @@ class _BranchesScreenState extends State<BranchesScreen> {
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Could not open WhatsApp.')));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open WhatsApp.')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Find Branch')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _branches.isEmpty
? const Center(child: Text('No branches available.', style: TextStyle(fontSize: 16)))
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
itemCount: _branches.length,
itemBuilder: (context, index) {
final branch = _branches[index];
final street = branch['street'] != null && branch['street'] != false ? branch['street'] : '';
final city = branch['city'] != null && branch['city'] != false ? branch['city'] : '';
final phone = branch['phone'] != null && branch['phone'] != false ? branch['phone'] : '';
final addressParts = [street, city].where((e) => e.toString().isNotEmpty).join(', ');
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: const BoxDecoration(
color: AppTheme.surfaceContainerLow,
borderRadius: BorderRadius.zero,
// Spec rules: "Don't use standard drop shadows"
),
child: ListTile(
contentPadding: const EdgeInsets.all(16),
onTap: () => _launchMaps('${branch['name']} ${addressParts}'),
leading: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.secondaryContainer.withOpacity(0.2),
shape: BoxShape.rectangle,
),
child: const Icon(Icons.storefront, color: AppTheme.secondary),
),
title: Text(
branch['name'] ?? 'Mapan Branch',
style: Theme.of(context).textTheme.titleMedium,
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
addressParts.isEmpty ? 'No address specified' : addressParts,
style: Theme.of(context).textTheme.bodyMedium,
return _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _fetchBranchesWithLocation,
child: Column(
children: [
// Location status banner
if (_locationDenied)
Container(
width: double.infinity,
color: AppTheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
const Icon(Icons.location_off, size: 16, color: AppTheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(
child: Text(
'Location not available. Showing branches alphabetically.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.onSurfaceVariant,
),
if (phone.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
),
),
],
),
)
else if (_userPosition != null)
Container(
width: double.infinity,
color: AppTheme.surfaceContainerLow,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
const Icon(Icons.my_location, size: 16, color: AppTheme.secondary),
const SizedBox(width: 8),
Text(
'Sorted by distance from your location',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.onSurfaceVariant,
),
),
],
),
),
// Branch list
Expanded(
child: _branches.isEmpty
? const Center(
child: Text('No branches available.', style: TextStyle(fontSize: 16)),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
itemCount: _branches.length,
itemBuilder: (context, index) {
final branch = _branches[index];
final street = branch['street'] != null && branch['street'] != false
? branch['street']
: '';
final city = branch['city'] != null && branch['city'] != false
? branch['city']
: '';
final phone = branch['phone'] != null && branch['phone'] != false
? branch['phone']
: '';
final addressParts = [street, city]
.where((e) => e.toString().isNotEmpty)
.join(', ');
final distance = _userPosition != null
? _distanceTo(_userPosition!, branch)
: null;
final distanceLabel =
distance != null ? _formatDistance(distance) : '';
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: const BoxDecoration(
color: AppTheme.surfaceContainerLow,
borderRadius: BorderRadius.zero,
),
child: ListTile(
contentPadding: const EdgeInsets.all(16),
onTap: () => _launchMaps(
'${branch['name']} $addressParts'),
leading: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.secondaryContainer
.withValues(alpha: 0.2),
shape: BoxShape.rectangle,
),
child: const Icon(Icons.storefront,
color: AppTheme.secondary),
),
title: Row(
children: [
Expanded(
child: Text(
branch['name'] ?? 'Mapan Branch',
style: Theme.of(context)
.textTheme
.titleMedium,
),
),
if (distanceLabel.isNotEmpty)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: const BoxDecoration(
color: AppTheme.secondaryContainer,
),
child: Text(
distanceLabel,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
color: AppTheme.onSecondaryContainer,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.phone, size: 14, color: AppTheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(phone, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: AppTheme.onSurfaceVariant)),
Text(
addressParts.isEmpty
? 'No address specified'
: addressParts,
style: Theme.of(context).textTheme.bodyMedium,
),
if (phone.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.phone,
size: 14,
color: AppTheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(phone,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: AppTheme.onSurfaceVariant)),
],
),
]
],
),
]
],
),
),
trailing: phone.isNotEmpty
? IconButton(
icon: const Icon(Icons.chat_bubble, color: AppTheme.onSurface),
onPressed: () => _launchWhatsApp(phone),
tooltip: 'Chat on WhatsApp',
)
: const Icon(Icons.chevron_right, color: AppTheme.onSurfaceVariant),
),
trailing: phone.isNotEmpty
? IconButton(
icon: const Icon(Icons.chat_bubble,
color: AppTheme.onSurface),
onPressed: () => _launchWhatsApp(phone),
tooltip: 'Chat on WhatsApp',
)
: const Icon(Icons.chevron_right,
color: AppTheme.onSurfaceVariant),
),
);
},
),
);
},
),
);
],
),
);
}
}

View File

@ -4,7 +4,7 @@ import 'package:odoo_rpc/odoo_rpc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/odoo_service.dart';
import '../services/config.dart';
import 'loyalty_dashboard.dart';
import 'main_shell.dart';
import 'branches_screen.dart';
import 'activation_screen.dart';
import 'signup_screen.dart';
@ -43,7 +43,7 @@ class _LoginScreenState extends State<LoginScreen> {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => LoyaltyDashboard(partnerId: session.partnerId),
builder: (_) => MainShell(partnerId: session.partnerId),
),
);
}

View File

@ -1,13 +1,12 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/odoo_service.dart';
import '../services/notification_service.dart';
import '../theme/app_theme.dart';
import 'notifications_screen.dart';
import 'branches_screen.dart';
import 'settings_screen.dart';
import '../widgets/carousel_widget.dart';
import '../widgets/promo_card_widget.dart';
import '../widgets/subscription_list_widget.dart';
/// Home tab shows loyalty card, subscriptions, carousel, and promo highlights.
/// Notification polling and AppBar are handled by MainShell.
class LoyaltyDashboard extends StatefulWidget {
final int partnerId;
const LoyaltyDashboard({super.key, required this.partnerId});
@ -18,249 +17,166 @@ class LoyaltyDashboard extends StatefulWidget {
class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
List<dynamic> _loyaltyCards = [];
List<dynamic> _subscriptions = [];
List<dynamic> _carouselSlides = [];
List<dynamic> _promos = [];
bool _isLoading = true;
int _unreadNotificationCount = 0;
Timer? _notificationTimer;
// Shared pref keys
static const _kLastNotified = 'last_device_notified_id';
@override
void initState() {
super.initState();
_fetchLoyaltyData();
_fetchNotificationCount();
_notificationTimer = Timer.periodic(const Duration(seconds: 10), (_) {
_fetchNotificationCount();
});
_fetchAll();
}
@override
void dispose() {
_notificationTimer?.cancel();
super.dispose();
}
Future<void> _fetchNotificationCount() async {
Future<void> _fetchAll() async {
setState(() => _isLoading = true);
try {
final client = OdooService().client;
if (client == null) return;
final results = await Future.wait([
OdooService().getLoyaltyCards(widget.partnerId),
OdooService().getSubscriptionCards(widget.partnerId),
OdooService().getCmsContent(),
]);
final response = await client.callRPC(
'/api/loyalty/fetch_notifications',
'call',
{'last_id': 0},
);
final cards = results[0] as List<dynamic>;
final subs = results[1] as List<dynamic>;
final cms = results[2] as Map<String, dynamic>;
if (response != null && response['status'] == 'success') {
final List<dynamic> notifs = response['data'] ?? [];
final prefs = await SharedPreferences.getInstance();
final lastNotifiedId = prefs.getInt(_kLastNotified) ?? 0;
// Check read list
final readIds = prefs.getStringList('read_notification_ids');
int unreadCount = 0;
if (readIds == null) {
final initialRead = notifs.map((n) => (n['id'] as int? ?? 0).toString()).toList();
await prefs.setStringList('read_notification_ids', initialRead);
unreadCount = 0;
} else {
unreadCount = notifs
.where((n) => !readIds.contains((n['id'] as int? ?? 0).toString()))
.length;
}
int highestNewId = lastNotifiedId;
final List<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);
}
if (mounted) {
setState(() {
_loyaltyCards = cards;
_subscriptions = subs;
_carouselSlides = (cms['carousel'] as List<dynamic>?) ?? [];
_promos = (cms['promos'] as List<dynamic>?) ?? [];
_isLoading = false;
});
}
} catch (e) {
// ignore
}
}
Future<void> _fetchLoyaltyData() async {
try {
final cards = await OdooService().getLoyaltyCards(widget.partnerId);
await _fetchNotificationCount();
setState(() {
_loyaltyCards = cards;
_isLoading = false;
});
} catch (e) {
if (mounted) {
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading loyalty cards: $e')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error loading data: $e')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('My Rewards'),
actions: [
IconButton(
icon: const Icon(Icons.storefront),
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const BranchesScreen())),
),
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
icon: const Icon(Icons.notifications),
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (_) => const NotificationsScreen()));
_fetchNotificationCount();
},
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
return RefreshIndicator(
onRefresh: _fetchAll,
child: ListView(
children: [
// Loyalty Card
if (_loyaltyCards.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 0),
child: Text(
'No active loyalty card yet.',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
if (_unreadNotificationCount > 0)
Positioned(
right: 8,
top: 8,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
constraints: const BoxConstraints(
minWidth: 16,
minHeight: 16,
),
child: Text(
'$_unreadNotificationCount',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
],
),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())),
),
const SizedBox(width: 8),
)
else
..._loyaltyCards.map((card) => _LoyaltyCardTile(card: card)),
// Subscriptions
if (_subscriptions.isNotEmpty)
SubscriptionListWidget(subscriptions: _subscriptions),
const SizedBox(height: 20),
// Carousel
if (_carouselSlides.isNotEmpty) ...[
CarouselWidget(slides: _carouselSlides),
const SizedBox(height: 24),
],
// Promo Highlights
if (_promos.isNotEmpty) ...[
PromoCardRow(promos: _promos),
const SizedBox(height: 24),
],
],
),
);
}
}
class _LoyaltyCardTile extends StatelessWidget {
final dynamic card;
const _LoyaltyCardTile({required this.card});
@override
Widget build(BuildContext context) {
final programName = (card['program_id']?[1] as String? ?? '').toLowerCase();
String tier = 'Member';
if (programName.contains('silver')) tier = 'Silver Member';
if (programName.contains('gold')) tier = 'Gold Member';
if (programName.contains('platinum')) tier = 'Platinum Member';
return Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: AppTheme.surfaceContainerHighest,
borderRadius: BorderRadius.zero,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'${card['program_id']?[1] ?? 'Loyalty Program'}',
style: Theme.of(context).textTheme.titleLarge,
softWrap: true,
),
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: const BoxDecoration(
color: AppTheme.secondaryContainer,
borderRadius: BorderRadius.zero,
),
child: Text(
tier,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: AppTheme.onSecondaryContainer,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 20),
Text('Membership Code', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 4),
Text('${card['code'] ?? 'N/A'}',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Available Points',
style: Theme.of(context).textTheme.bodyMedium),
Text(
'${card['points'] ?? 0}',
style: Theme.of(context).textTheme.displayMedium?.copyWith(
color: AppTheme.primary,
),
),
],
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _fetchLoyaltyData,
child: _loyaltyCards.isEmpty
? Center(
child: Text(
'No active rewards yet.',
style: Theme.of(context).textTheme.titleLarge,
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 40.0),
itemCount: _loyaltyCards.length,
itemBuilder: (context, index) {
final card = _loyaltyCards[index];
return Container(
margin: const EdgeInsets.only(bottom: 40),
padding: const EdgeInsets.all(32),
decoration: const BoxDecoration(
color: AppTheme.surfaceContainerHighest, // Soft Lift without shadow
borderRadius: BorderRadius.zero,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'${card['program_id']?[1] ?? 'Loyalty Program'}',
style: Theme.of(context).textTheme.titleLarge,
softWrap: true,
),
),
const SizedBox(width: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: const BoxDecoration(
color: AppTheme.secondaryContainer,
borderRadius: BorderRadius.zero, // Editorial block
),
child: Text(
(() {
final programName = (card['program_id']?[1] as String? ?? '').toLowerCase();
if (programName.contains('silver')) return 'Silver Member';
if (programName.contains('gold')) return 'Gold Member';
if (programName.contains('platinum')) return 'Platinum Member';
return 'Member';
})(),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: AppTheme.onSecondaryContainer,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 32),
Text('Membership Code', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 4),
Text('${card['code'] ?? 'N/A'}', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Available Points', style: Theme.of(context).textTheme.bodyMedium),
Text(
'${card['points'] ?? 0}',
style: Theme.of(context).textTheme.displayMedium?.copyWith(
color: AppTheme.primary,
),
),
],
),
],
),
);
},
),
),
);
}
}

204
lib/screens/main_shell.dart Normal file
View 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],
);
}),
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import '../theme/app_theme.dart';
import '../services/odoo_service.dart';
@ -10,20 +11,19 @@ class NotificationDetailScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final title = notif['title'] as String? ?? 'Notice';
final body = notif['body'] as String? ?? '';
final bodyHtml = notif['body'] as String? ?? '';
return Scaffold(
appBar: AppBar(
title: const Text('Notification Detail'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header icon block
// Header block
Container(
padding: const EdgeInsets.symmetric(vertical: 32),
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24),
color: AppTheme.surfaceContainerLow,
child: Column(
children: [
@ -40,89 +40,115 @@ class NotificationDetailScreen extends StatelessWidget {
),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
title,
textAlign: TextAlign.center,
style:
Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppTheme.onSurface,
fontSize: 20,
),
),
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppTheme.onSurface,
fontSize: 20,
),
),
],
),
),
// Full image (authenticated)
if (notif['has_image'] == true) ...[
const SizedBox(height: 24),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
OdooService().notificationImageUrl(notif['id'] as int),
headers: {
'Cookie': OdooService().sessionCookie,
},
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
height: 200,
color: AppTheme.surfaceContainerLow,
child: const Center(child: CircularProgressIndicator()),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
height: 150,
color: AppTheme.surfaceContainerLow,
child: const Center(
child: Icon(Icons.broken_image_outlined, size: 48, color: AppTheme.onSurfaceVariant),
),
);
},
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ClipRect(
child: Image.network(
OdooService().notificationImageUrl(notif['id'] as int),
headers: {'Cookie': OdooService().sessionCookie},
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
height: 200,
color: AppTheme.surfaceContainerLow,
child: const Center(child: CircularProgressIndicator()),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
height: 150,
color: AppTheme.surfaceContainerLow,
child: const Center(
child: Icon(Icons.broken_image_outlined,
size: 48, color: AppTheme.onSurfaceVariant),
),
);
},
),
),
),
],
const SizedBox(height: 24),
const SizedBox(height: 20),
// Body label
Text(
'Message',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: AppTheme.onSurfaceVariant,
letterSpacing: 1.2,
),
// Message label
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
'MESSAGE',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: AppTheme.onSurfaceVariant,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 8),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Divider(
color: AppTheme.surfaceContainerHighest,
thickness: 2,
height: 2,
),
),
const SizedBox(height: 8),
// Divider line (editorial style)
const Divider(
color: AppTheme.surfaceContainerHighest,
thickness: 2,
height: 2,
),
const SizedBox(height: 16),
// Body text
body.isEmpty
? Text(
'No additional details.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
// Rich HTML body
bodyHtml.isEmpty
? Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No additional details.',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: AppTheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
)
: Text(
body,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Html(
data: bodyHtml,
style: {
'body': Style(
color: AppTheme.onSurface,
height: 1.7,
fontFamily: 'Manrope',
fontSize: FontSize(15),
lineHeight: const LineHeight(1.7),
),
'p': Style(margin: Margins.only(bottom: 12)),
'h1': Style(
color: AppTheme.onSurface,
fontSize: FontSize(22),
fontWeight: FontWeight.bold,
),
'h2': Style(
color: AppTheme.onSurface,
fontSize: FontSize(18),
fontWeight: FontWeight.bold,
),
'a': Style(color: AppTheme.secondary),
},
),
),
const SizedBox(height: 40),
],
),
),

View 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,
),
],
),
),
);
}
}

View 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),
),
);
}
}
}

View File

@ -1,5 +1,6 @@
import 'package:odoo_rpc/odoo_rpc.dart';
import 'config.dart';
import 'theme_manager.dart';
class OdooService {
static final OdooService _instance = OdooService._internal();
@ -13,7 +14,6 @@ class OdooService {
}
/// Returns the session cookie header value for authenticated image loading.
/// Usage: Image.network(url, headers: {'Cookie': OdooService().sessionCookie})
String get sessionCookie {
final sessionId = client?.sessionId?.id ?? '';
return 'session_id=$sessionId';
@ -23,6 +23,14 @@ class OdooService {
String notificationImageUrl(int notifId) =>
'${AppConfig.odooUrl}/web/image/mapan.app.notification/$notifId/image';
/// Returns the URL for a carousel image (uploaded).
String carouselImageUrl(int slideId) =>
'${AppConfig.odooUrl}/web/image/mapan.app.carousel/$slideId/image';
/// Returns the URL for a promo image (uploaded).
String promoImageUrl(int promoId) =>
'${AppConfig.odooUrl}/web/image/mapan.app.promo/$promoId/image';
Future<OdooSession> login(String db, String username, String password) async {
if (client == null) throw Exception("Connect to Odoo first");
return await client!.authenticate(db, username, password);
@ -31,16 +39,102 @@ class OdooService {
Future<List<dynamic>> getLoyaltyCards(int partnerId) async {
if (client == null) throw Exception("Connect to Odoo first");
// Only fetch cards from 'loyalty' type programs (multi-tier: Silver/Gold/Platinum).
// Excludes subscriptions, coupons, gift cards, promotions, eWallets, etc.
return await client!.callKw({
'model': 'loyalty.card',
'method': 'search_read',
'args': [
[['partner_id', '=', partnerId]],
[
['partner_id', '=', partnerId],
['program_id.program_type', '=', 'loyalty'],
],
],
'kwargs': {'fields': ['points', 'program_id', 'code']}
}) as List<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({
String? email,
String? phone,
@ -134,15 +228,14 @@ class OdooService {
);
}
/// Fetch public branch information using our secure Odoo endpoint
/// This completely isolates the Admin API Key from the Flutter Source Code!
/// Fetch public branch information (includes lat/lng for geolocation sorting).
static Future<List<dynamic>> getPublicBranches() async {
final tempClient = OdooClient(AppConfig.odooUrl);
try {
final res = await tempClient.callRPC(
'/api/loyalty/branches',
'call',
{}
'/api/loyalty/branches',
'call',
{}
);
if (res != null && res['status'] == 'success') {
return res['data'] as List<dynamic>;

View 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;
}
}
}

View File

@ -23,23 +23,31 @@ class AppTheme {
static const Color onPrimary = Color(0xFFFFF59B);
static const Color outlineVariant = Color(0xFFACADAB);
static ThemeData get lightTheme {
static ThemeData get lightTheme => getTheme();
static ThemeData getTheme({Color? primaryColor, Color? secondaryColor}) {
final baseTheme = ThemeData.light();
final pColor = primaryColor ?? primary;
final sColor = secondaryColor ?? secondary;
// Dynamically compute readable contrast text colors
final onPrimaryColor = pColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
final onSecondaryColor = sColor.computeLuminance() > 0.5 ? Colors.black : Colors.white;
return ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: surface,
colorScheme: const ColorScheme.light(
primary: primary,
primaryContainer: primaryContainer,
secondary: secondary,
secondaryContainer: secondaryContainer,
onSecondaryContainer: onSecondaryContainer,
colorScheme: ColorScheme.light(
primary: pColor,
primaryContainer: pColor,
secondary: sColor,
secondaryContainer: sColor,
onSecondaryContainer: onSecondaryColor,
surface: surface,
onSurface: onSurface,
onSurfaceVariant: onSurfaceVariant,
onPrimary: onPrimary,
error: Color(0xFFB02500),
onPrimary: onPrimaryColor,
error: const Color(0xFFB02500),
),
textTheme: baseTheme.textTheme.copyWith(
displayLarge: GoogleFonts.epilogue(
@ -84,8 +92,8 @@ class AppTheme {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
foregroundColor: onPrimaryContainer,
backgroundColor: primaryContainer,
foregroundColor: onPrimaryColor,
backgroundColor: pColor,
elevation: 0,
side: const BorderSide(color: Colors.red, width: 2),
),
@ -109,7 +117,7 @@ class AppTheme {
borderRadius: BorderRadius.zero,
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: primary.withOpacity(0.5), width: 2),
borderSide: BorderSide(color: pColor.withValues(alpha: 0.5), width: 2),
borderRadius: BorderRadius.zero,
),
labelStyle: const TextStyle(color: onSurfaceVariant),

View 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),
),
);
}
}

View 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),
),
);
}
}

View 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),
),
),
),
],
),
);
}
}

View File

@ -7,12 +7,14 @@ import Foundation
import app_badge_plus
import flutter_local_notifications
import geolocator_apple
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AppBadgePlusPlugin.register(with: registry.registrar(forPlugin: "AppBadgePlusPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}

View File

@ -105,6 +105,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@ -145,11 +153,27 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_html:
dependency: "direct main"
description:
name: flutter_html
sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
@ -208,6 +232,54 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
geolocator:
dependency: "direct main"
description:
name: geolocator
sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2
url: "https://pub.dev"
source: hosted
version: "13.0.4"
geolocator_android:
dependency: transitive
description:
name: geolocator_android
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
url: "https://pub.dev"
source: hosted
version: "4.6.2"
geolocator_apple:
dependency: transitive
description:
name: geolocator_apple
sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73"
url: "https://pub.dev"
source: hosted
version: "2.3.14"
geolocator_platform_interface:
dependency: transitive
description:
name: geolocator_platform_interface
sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2
url: "https://pub.dev"
source: hosted
version: "4.2.8"
geolocator_web:
dependency: transitive
description:
name: geolocator_web
sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429"
url: "https://pub.dev"
source: hosted
version: "4.1.4"
geolocator_windows:
dependency: transitive
description:
name: geolocator_windows
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
url: "https://pub.dev"
source: hosted
version: "0.2.5"
glob:
dependency: transitive
description:
@ -232,6 +304,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.2"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: transitive
description:
@ -296,6 +376,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
list_counter:
dependency: transitive
description:
name: list_counter
sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237
url: "https://pub.dev"
source: hosted
version: "1.0.2"
logging:
dependency: transitive
description:
@ -557,6 +645,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
smooth_page_indicator:
dependency: "direct main"
description:
name: smooth_page_indicator
sha256: b21ebb8bc39cf72d11c7cfd809162a48c3800668ced1c9da3aade13a32cf6c1c
url: "https://pub.dev"
source: hosted
version: "1.2.1"
source_span:
dependency: transitive
description:
@ -685,6 +781,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:

View File

@ -42,6 +42,9 @@ dependencies:
url_launcher: ^6.3.2
app_badge_plus: ^1.3.1
permission_handler: ^12.0.3
geolocator: ^13.0.2
smooth_page_indicator: ^1.2.0
flutter_html: ^3.0.0-beta.2
dev_dependencies:
flutter_test:

View File

@ -6,10 +6,13 @@
#include "generated_plugin_registrant.h"
#include <geolocator_windows/geolocator_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
GeolocatorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GeolocatorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
geolocator_windows
permission_handler_windows
url_launcher_windows
)