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.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"

View File

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

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

View File

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

View File

@ -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
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/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),
], ],
), ),
), ),

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 '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);
@ -31,16 +39,102 @@ 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>;

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 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),

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 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"))
} }

View File

@ -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:

View File

@ -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:

View File

@ -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(

View File

@ -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
) )