feat: implement dynamic theming, app shell navigation, and expanded Odoo API services for CMS, orders, and subscriptions.
This commit is contained in:
parent
f0c2942861
commit
c32589aba4
File diff suppressed because one or more lines are too long
@ -2,6 +2,8 @@
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.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"
|
||||
|
||||
@ -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 ListenableBuilder(
|
||||
listenable: ThemeManager.instance,
|
||||
builder: (context, _) {
|
||||
return MaterialApp(
|
||||
title: 'Mie Mapan Loyalty App',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
theme: ThemeManager.instance.themeData,
|
||||
home: homeWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
354
lib/screens/account_screen.dart
Normal file
354
lib/screens/account_screen.dart
Normal file
@ -0,0 +1,354 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../services/odoo_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/agreement_dialog.dart';
|
||||
import 'login_screen.dart';
|
||||
|
||||
class AccountScreen extends StatefulWidget {
|
||||
const AccountScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AccountScreen> createState() => _AccountScreenState();
|
||||
}
|
||||
|
||||
class _AccountScreenState extends State<AccountScreen> {
|
||||
final _phraseController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
|
||||
String _aboutUsUrl = '';
|
||||
String _contactUsUrl = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAppConfig();
|
||||
}
|
||||
|
||||
Future<void> _loadAppConfig() async {
|
||||
try {
|
||||
final config = await OdooService.getAppConfig();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_aboutUsUrl = config['about_us_url'] ?? '';
|
||||
_contactUsUrl = config['contact_us_url'] ?? '';
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _launchUrl(String url) async {
|
||||
if (url.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('URL not configured yet.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not open link.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showTerms() {
|
||||
AgreementDialog.show(context, 'Terms & Conditions', AgreementTexts.termsAndConditions);
|
||||
}
|
||||
|
||||
void _showPrivacy() {
|
||||
AgreementDialog.show(context, 'Privacy Policy', AgreementTexts.privacyPolicy);
|
||||
}
|
||||
|
||||
void _logout() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('odoo_session');
|
||||
await prefs.remove('last_seen_notification_id');
|
||||
await prefs.remove('last_device_notified_id');
|
||||
await prefs.remove('read_notification_ids');
|
||||
await NotificationService().clearBadge();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showDeleteConfirmationDialog() {
|
||||
_phraseController.clear();
|
||||
_passwordController.clear();
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'Delete Account Permanently',
|
||||
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text(
|
||||
'WARNING: This is a permanent action. All your loyalty points, card tier history, and reward history will be deleted and cannot be recovered.',
|
||||
style: TextStyle(
|
||||
color: AppTheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'To confirm, type "DELETE MY ACCOUNT" below:',
|
||||
style: TextStyle(color: AppTheme.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _phraseController,
|
||||
decoration: const InputDecoration(hintText: 'DELETE MY ACCOUNT'),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Enter your current password:',
|
||||
style: TextStyle(color: AppTheme.onSurfaceVariant, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(hintText: 'Password'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel',
|
||||
style: TextStyle(color: AppTheme.onSurface, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () async {
|
||||
final phrase = _phraseController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
if (phrase != 'DELETE MY ACCOUNT') {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Verification phrase is incorrect.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (password.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please enter your password.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDialogState(() => _isLoading = true);
|
||||
try {
|
||||
final service = OdooService();
|
||||
final response = await service.deleteAccount(password);
|
||||
if (response != null && response['status'] == 'success') {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('odoo_session');
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(response['message'] ?? 'Account deleted.')),
|
||||
);
|
||||
Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(response?['message'] ?? 'Deletion failed.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Error: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setDialogState(() => _isLoading = false);
|
||||
}
|
||||
},
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.red),
|
||||
)
|
||||
: const Text('Delete My Account',
|
||||
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phraseController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Info Section ─────────────────────────────────────────────
|
||||
_SectionHeader(label: 'Info'),
|
||||
_MenuItem(
|
||||
icon: Icons.info_outline_rounded,
|
||||
label: 'About Us',
|
||||
onTap: () => _launchUrl(_aboutUsUrl),
|
||||
),
|
||||
_MenuItem(
|
||||
icon: Icons.phone_rounded,
|
||||
label: 'Contact Us',
|
||||
onTap: () => _launchUrl(_contactUsUrl),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Legal Section ────────────────────────────────────────────
|
||||
_SectionHeader(label: 'Legal'),
|
||||
_MenuItem(
|
||||
icon: Icons.description_outlined,
|
||||
label: 'Terms & Conditions',
|
||||
onTap: _showTerms,
|
||||
),
|
||||
_MenuItem(
|
||||
icon: Icons.lock_outline_rounded,
|
||||
label: 'Privacy Policy',
|
||||
onTap: _showPrivacy,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Account Section ──────────────────────────────────────────
|
||||
_SectionHeader(label: 'Account'),
|
||||
_MenuItem(
|
||||
icon: Icons.logout_rounded,
|
||||
label: 'Log Out',
|
||||
onTap: _logout,
|
||||
),
|
||||
_MenuItem(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
label: 'Delete Account',
|
||||
labelColor: const Color(0xFFB02500),
|
||||
iconColor: const Color(0xFFB02500),
|
||||
onTap: _showDeleteConfirmationDialog,
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String label;
|
||||
const _SectionHeader({required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
label.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: AppTheme.onSurfaceVariant,
|
||||
letterSpacing: 1.2,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final Color? labelColor;
|
||||
final Color? iconColor;
|
||||
|
||||
const _MenuItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.labelColor,
|
||||
this.iconColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppTheme.surfaceContainerLowest,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: AppTheme.surfaceContainer, width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon,
|
||||
size: 22,
|
||||
color: iconColor ?? AppTheme.onSurfaceVariant),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: labelColor ?? AppTheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(Icons.chevron_right,
|
||||
size: 20, color: AppTheme.outlineVariant),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,6 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package: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,51 +143,145 @@ 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
|
||||
return _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _branches.isEmpty
|
||||
? const Center(child: Text('No branches available.', style: TextStyle(fontSize: 16)))
|
||||
: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
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: 16),
|
||||
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 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 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: 16),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
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}'),
|
||||
onTap: () => _launchMaps(
|
||||
'${branch['name']} $addressParts'),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.secondaryContainer.withOpacity(0.2),
|
||||
color: AppTheme.secondaryContainer
|
||||
.withValues(alpha: 0.2),
|
||||
shape: BoxShape.rectangle,
|
||||
),
|
||||
child: const Icon(Icons.storefront, color: AppTheme.secondary),
|
||||
child: const Icon(Icons.storefront,
|
||||
color: AppTheme.secondary),
|
||||
),
|
||||
title: Text(
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
branch['name'] ?? 'Mapan Branch',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
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),
|
||||
@ -109,16 +289,25 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
addressParts.isEmpty ? 'No address specified' : addressParts,
|
||||
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 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(phone,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: AppTheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@ -127,15 +316,20 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
||||
),
|
||||
trailing: phone.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.chat_bubble, color: AppTheme.onSurface),
|
||||
icon: const Icon(Icons.chat_bubble,
|
||||
color: AppTheme.onSurface),
|
||||
onPressed: () => _launchWhatsApp(phone),
|
||||
tooltip: 'Chat on WhatsApp',
|
||||
)
|
||||
: const Icon(Icons.chevron_right, color: AppTheme.onSurfaceVariant),
|
||||
: const Icon(Icons.chevron_right,
|
||||
color: AppTheme.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import 'package:odoo_rpc/odoo_rpc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,187 +17,112 @@ 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},
|
||||
);
|
||||
|
||||
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);
|
||||
final cards = results[0] as List<dynamic>;
|
||||
final subs = results[1] as List<dynamic>;
|
||||
final cms = results[2] as Map<String, dynamic>;
|
||||
|
||||
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;
|
||||
_subscriptions = subs;
|
||||
_carouselSlides = (cms['carousel'] as List<dynamic>?) ?? [];
|
||||
_promos = (cms['promos'] as List<dynamic>?) ?? [];
|
||||
_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,
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _fetchAll,
|
||||
child: ListView(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications),
|
||||
onPressed: () async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const NotificationsScreen()));
|
||||
_fetchNotificationCount();
|
||||
},
|
||||
),
|
||||
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,
|
||||
),
|
||||
// ── Loyalty Card ─────────────────────────────────────────────
|
||||
if (_loyaltyCards.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 32, 24, 0),
|
||||
child: Text(
|
||||
'$_unreadNotificationCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
'No active loyalty card yet.',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
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];
|
||||
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.only(bottom: 40),
|
||||
padding: const EdgeInsets.all(32),
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.surfaceContainerHighest, // Soft Lift without shadow
|
||||
color: AppTheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
child: Column(
|
||||
@ -214,21 +138,15 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.zero, // Editorial block
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
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';
|
||||
})(),
|
||||
tier,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: AppTheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
@ -237,16 +155,18 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
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: 24),
|
||||
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('Available Points',
|
||||
style: Theme.of(context).textTheme.bodyMedium),
|
||||
Text(
|
||||
'${card['points'] ?? 0}',
|
||||
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
||||
@ -258,9 +178,5 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
204
lib/screens/main_shell.dart
Normal file
204
lib/screens/main_shell.dart
Normal file
@ -0,0 +1,204 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../services/odoo_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../services/theme_manager.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import 'notifications_screen.dart';
|
||||
import 'loyalty_dashboard.dart';
|
||||
import 'branches_screen.dart';
|
||||
import 'orders_screen.dart';
|
||||
import 'account_screen.dart';
|
||||
|
||||
class MainShell extends StatefulWidget {
|
||||
final int partnerId;
|
||||
const MainShell({super.key, required this.partnerId});
|
||||
|
||||
@override
|
||||
State<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
class _MainShellState extends State<MainShell> {
|
||||
int _currentIndex = 0;
|
||||
int _unreadNotificationCount = 0;
|
||||
Timer? _notificationTimer;
|
||||
|
||||
static const _kLastNotified = 'last_device_notified_id';
|
||||
|
||||
late final List<Widget> _pages;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pages = [
|
||||
LoyaltyDashboard(partnerId: widget.partnerId),
|
||||
const BranchesScreen(),
|
||||
const OrdersScreen(),
|
||||
const AccountScreen(),
|
||||
];
|
||||
_fetchNotificationCount();
|
||||
_notificationTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
_fetchNotificationCount();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_notificationTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchNotificationCount() async {
|
||||
try {
|
||||
final client = OdooService().client;
|
||||
if (client == null) return;
|
||||
|
||||
final response = await client.callRPC(
|
||||
'/api/loyalty/fetch_notifications',
|
||||
'call',
|
||||
{'last_id': 0},
|
||||
);
|
||||
|
||||
if (response != null && response['status'] == 'success') {
|
||||
final List<dynamic> notifs = response['data'] ?? [];
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastNotifiedId = prefs.getInt(_kLastNotified) ?? 0;
|
||||
final readIds = prefs.getStringList('read_notification_ids');
|
||||
|
||||
int unreadCount = 0;
|
||||
if (readIds == null) {
|
||||
final initialRead = notifs.map((n) => (n['id'] as int? ?? 0).toString()).toList();
|
||||
await prefs.setStringList('read_notification_ids', initialRead);
|
||||
unreadCount = 0;
|
||||
} else {
|
||||
unreadCount = notifs
|
||||
.where((n) => !readIds.contains((n['id'] as int? ?? 0).toString()))
|
||||
.length;
|
||||
}
|
||||
|
||||
int highestNewId = lastNotifiedId;
|
||||
final List<dynamic> toNotify = [];
|
||||
for (var notif in notifs) {
|
||||
final id = notif['id'] as int? ?? 0;
|
||||
if (id > lastNotifiedId) {
|
||||
toNotify.add(notif);
|
||||
if (id > highestNewId) highestNewId = id;
|
||||
}
|
||||
}
|
||||
|
||||
if (toNotify.isNotEmpty) {
|
||||
final notifService = NotificationService();
|
||||
for (final notif in toNotify) {
|
||||
await notifService.showNotification(
|
||||
id: notif['id'] as int,
|
||||
title: notif['title'] ?? 'Mie Mapan',
|
||||
body: notif['body'] ?? '',
|
||||
);
|
||||
}
|
||||
await prefs.setInt(_kLastNotified, highestNewId);
|
||||
}
|
||||
|
||||
await NotificationService().setBadge(unreadCount);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _unreadNotificationCount = unreadCount);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final navLabels = ['Home', 'Branches', 'Orders', 'Account'];
|
||||
final navIcons = [
|
||||
Icons.home_rounded,
|
||||
Icons.location_on_rounded,
|
||||
Icons.receipt_long_rounded,
|
||||
Icons.person_rounded,
|
||||
];
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: ThemeManager.instance.brandLogo.isNotEmpty
|
||||
? Image.memory(
|
||||
base64Decode(ThemeManager.instance.brandLogo),
|
||||
height: 36,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) => const Text('Mie Mapan'),
|
||||
)
|
||||
: const Text('Mie Mapan'),
|
||||
actions: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications_rounded),
|
||||
tooltip: 'Notifications',
|
||||
onPressed: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const NotificationsScreen()),
|
||||
);
|
||||
_fetchNotificationCount();
|
||||
},
|
||||
),
|
||||
if (_unreadNotificationCount > 0)
|
||||
Positioned(
|
||||
right: 6,
|
||||
top: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
||||
child: Text(
|
||||
_unreadNotificationCount > 99
|
||||
? '99+'
|
||||
: '$_unreadNotificationCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
body: IndexedStack(
|
||||
index: _currentIndex,
|
||||
children: _pages,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() => _currentIndex = index);
|
||||
},
|
||||
backgroundColor: AppTheme.surfaceContainerLowest,
|
||||
indicatorColor: colorScheme.primary,
|
||||
destinations: List.generate(4, (i) {
|
||||
return NavigationDestination(
|
||||
icon: Icon(navIcons[i],
|
||||
color: i == _currentIndex
|
||||
? colorScheme.onSecondaryContainer
|
||||
: colorScheme.onSurfaceVariant),
|
||||
selectedIcon: Icon(navIcons[i], color: colorScheme.onSecondaryContainer),
|
||||
label: navLabels[i],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_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,31 +40,27 @@ class NotificationDetailScreen extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
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),
|
||||
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,
|
||||
},
|
||||
headers: {'Cookie': OdooService().sessionCookie},
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
@ -79,50 +75,80 @@ class NotificationDetailScreen extends StatelessWidget {
|
||||
height: 150,
|
||||
color: AppTheme.surfaceContainerLow,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image_outlined, size: 48, color: AppTheme.onSurfaceVariant),
|
||||
child: Icon(Icons.broken_image_outlined,
|
||||
size: 48, color: AppTheme.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Body label
|
||||
Text(
|
||||
'Message',
|
||||
// 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),
|
||||
|
||||
// Divider line (editorial style)
|
||||
const Divider(
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Divider(
|
||||
color: AppTheme.surfaceContainerHighest,
|
||||
thickness: 2,
|
||||
height: 2,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Body text
|
||||
body.isEmpty
|
||||
? Text(
|
||||
// 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
47
lib/screens/orders_screen.dart
Normal file
47
lib/screens/orders_screen.dart
Normal file
@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Orders tab — placeholder screen for future ordering features.
|
||||
class OrdersScreen extends StatelessWidget {
|
||||
const OrdersScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(40),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(28),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceContainerLow,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.receipt_long_rounded,
|
||||
size: 56,
|
||||
color: AppTheme.secondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Text(
|
||||
'Orders',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Online ordering is coming soon!\nStay tuned for updates.',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: AppTheme.onSurfaceVariant,
|
||||
height: 1.6,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/screens/promo_detail_screen.dart
Normal file
99
lib/screens/promo_detail_screen.dart
Normal file
@ -0,0 +1,99 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Promo detail screen — mirrors notification detail but for promo highlights.
|
||||
/// Shows the promo image (full size), title, and rich HTML body content.
|
||||
class PromoDetailScreen extends StatelessWidget {
|
||||
final dynamic promo;
|
||||
const PromoDetailScreen({super.key, required this.promo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = promo['name'] as String? ?? 'Promo';
|
||||
final bodyHtml = promo['body'] as String? ?? '';
|
||||
final base64Img = promo['image_128'] as String?;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Promo image (full width)
|
||||
if (base64Img != null && base64Img.isNotEmpty)
|
||||
_buildPromoImage(base64Img),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
|
||||
if (bodyHtml.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Html(
|
||||
data: bodyHtml,
|
||||
style: {
|
||||
'body': Style(
|
||||
color: AppTheme.onSurface,
|
||||
fontFamily: 'Manrope',
|
||||
fontSize: FontSize(15),
|
||||
lineHeight: const LineHeight(1.6),
|
||||
),
|
||||
'p': Style(margin: Margins.only(bottom: 12)),
|
||||
'h1': Style(
|
||||
color: AppTheme.onSurface,
|
||||
fontSize: FontSize(22),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
'h2': Style(
|
||||
color: AppTheme.onSurface,
|
||||
fontSize: FontSize(18),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
'a': Style(color: AppTheme.secondary),
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text(
|
||||
'No content available.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPromoImage(String base64Img) {
|
||||
try {
|
||||
final Uint8List bytes = base64Decode(base64Img);
|
||||
return Image.memory(
|
||||
bytes,
|
||||
width: double.infinity,
|
||||
height: 220,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
} catch (_) {
|
||||
return Container(
|
||||
height: 220,
|
||||
color: AppTheme.surfaceContainer,
|
||||
child: const Center(
|
||||
child: Icon(Icons.local_offer_rounded, size: 56, color: AppTheme.outlineVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import 'package:odoo_rpc/odoo_rpc.dart';
|
||||
import '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,8 +228,7 @@ 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 {
|
||||
|
||||
67
lib/services/theme_manager.dart
Normal file
67
lib/services/theme_manager.dart
Normal file
@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Manages dynamic branding and app theme settings fetched from the Odoo backend.
|
||||
/// Automatically handles offline caching via SharedPreferences.
|
||||
class ThemeManager extends ChangeNotifier {
|
||||
static final ThemeManager instance = ThemeManager._internal();
|
||||
|
||||
ThemeManager._internal();
|
||||
|
||||
Color _primaryColor = AppTheme.primary;
|
||||
Color _secondaryColor = AppTheme.secondary;
|
||||
String _brandLogo = '';
|
||||
|
||||
Color get primaryColor => _primaryColor;
|
||||
Color get secondaryColor => _secondaryColor;
|
||||
String get brandLogo => _brandLogo;
|
||||
|
||||
ThemeData get themeData => AppTheme.getTheme(
|
||||
primaryColor: _primaryColor,
|
||||
secondaryColor: _secondaryColor,
|
||||
);
|
||||
|
||||
/// Initialize cached settings on app launch
|
||||
Future<void> initialize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final primHex = prefs.getString('theme_primary_color');
|
||||
final secHex = prefs.getString('theme_secondary_color');
|
||||
_brandLogo = prefs.getString('theme_brand_logo') ?? '';
|
||||
|
||||
if (primHex != null) {
|
||||
_primaryColor = _parseHexColor(primHex) ?? AppTheme.primary;
|
||||
}
|
||||
if (secHex != null) {
|
||||
_secondaryColor = _parseHexColor(secHex) ?? AppTheme.secondary;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update theme options and persist them to SharedPreferences
|
||||
Future<void> updateConfig({
|
||||
required String primaryHex,
|
||||
required String secondaryHex,
|
||||
required String brandLogoB64,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('theme_primary_color', primaryHex);
|
||||
await prefs.setString('theme_secondary_color', secondaryHex);
|
||||
await prefs.setString('theme_brand_logo', brandLogoB64);
|
||||
|
||||
_primaryColor = _parseHexColor(primaryHex) ?? AppTheme.primary;
|
||||
_secondaryColor = _parseHexColor(secondaryHex) ?? AppTheme.secondary;
|
||||
_brandLogo = brandLogoB64;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Color? _parseHexColor(String hexString) {
|
||||
try {
|
||||
final buffer = StringBuffer();
|
||||
if (hexString.length == 6 || hexString.length == 7) buffer.write('ff');
|
||||
buffer.write(hexString.replaceFirst('#', ''));
|
||||
return Color(int.parse(buffer.toString(), radix: 16));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,23 +23,31 @@ class AppTheme {
|
||||
static const Color onPrimary = Color(0xFFFFF59B);
|
||||
static const Color 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),
|
||||
|
||||
149
lib/widgets/carousel_widget.dart
Normal file
149
lib/widgets/carousel_widget.dart
Normal file
@ -0,0 +1,149 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Auto-scrolling carousel widget that shows slides from CMS.
|
||||
/// Each slide can have an uploaded image (base64) or external image URL.
|
||||
class CarouselWidget extends StatefulWidget {
|
||||
final List<dynamic> slides;
|
||||
|
||||
const CarouselWidget({super.key, required this.slides});
|
||||
|
||||
@override
|
||||
State<CarouselWidget> createState() => _CarouselWidgetState();
|
||||
}
|
||||
|
||||
class _CarouselWidgetState extends State<CarouselWidget> {
|
||||
final PageController _controller = PageController();
|
||||
int _current = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.slides.length > 1) {
|
||||
Future.delayed(const Duration(seconds: 4), _autoScroll);
|
||||
}
|
||||
}
|
||||
|
||||
void _autoScroll() {
|
||||
if (!mounted) return;
|
||||
final next = (_current + 1) % widget.slides.length;
|
||||
_controller.animateToPage(
|
||||
next,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
Future.delayed(const Duration(seconds: 4), _autoScroll);
|
||||
}
|
||||
|
||||
Future<void> _onTap(dynamic slide) async {
|
||||
final linkUrl = slide['link_url'] as String?;
|
||||
if (linkUrl != null && linkUrl.isNotEmpty) {
|
||||
final uri = Uri.tryParse(linkUrl);
|
||||
if (uri != null && await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.slides.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 180,
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: widget.slides.length,
|
||||
onPageChanged: (i) => setState(() => _current = i),
|
||||
itemBuilder: (context, index) {
|
||||
final slide = widget.slides[index];
|
||||
return GestureDetector(
|
||||
onTap: () => _onTap(slide),
|
||||
child: _SlideImage(slide: slide),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (widget.slides.length > 1)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: SmoothPageIndicator(
|
||||
controller: _controller,
|
||||
count: widget.slides.length,
|
||||
effect: ExpandingDotsEffect(
|
||||
dotHeight: 6,
|
||||
dotWidth: 6,
|
||||
expansionFactor: 3,
|
||||
dotColor: AppTheme.surfaceContainer,
|
||||
activeDotColor: AppTheme.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlideImage extends StatelessWidget {
|
||||
final dynamic slide;
|
||||
const _SlideImage({required this.slide});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final base64Img = slide['image'] as String?;
|
||||
final externalUrl = slide['image_url'] as String?;
|
||||
|
||||
Widget image;
|
||||
|
||||
if (base64Img != null && base64Img.isNotEmpty) {
|
||||
// Uploaded image — decode base64
|
||||
try {
|
||||
final Uint8List bytes = base64Decode(base64Img);
|
||||
image = Image.memory(bytes, fit: BoxFit.cover, width: double.infinity);
|
||||
} catch (_) {
|
||||
image = _placeholder();
|
||||
}
|
||||
} else if (externalUrl != null && externalUrl.isNotEmpty) {
|
||||
// External URL image
|
||||
image = Image.network(
|
||||
externalUrl,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
errorBuilder: (_, __, ___) => _placeholder(),
|
||||
loadingBuilder: (ctx, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
);
|
||||
} else {
|
||||
image = _placeholder();
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: const BoxDecoration(color: AppTheme.surfaceContainerLow),
|
||||
child: ClipRect(child: image),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placeholder() {
|
||||
return Container(
|
||||
color: AppTheme.surfaceContainer,
|
||||
child: const Center(
|
||||
child: Icon(Icons.image_rounded, size: 48, color: AppTheme.outlineVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
114
lib/widgets/promo_card_widget.dart
Normal file
114
lib/widgets/promo_card_widget.dart
Normal file
@ -0,0 +1,114 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../screens/promo_detail_screen.dart';
|
||||
|
||||
/// Horizontal scrollable row of promo highlight cards.
|
||||
/// Tapping a card opens the full detail screen with rich text content.
|
||||
class PromoCardRow extends StatelessWidget {
|
||||
final List<dynamic> promos;
|
||||
|
||||
const PromoCardRow({super.key, required this.promos});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (promos.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||
child: Text(
|
||||
'Promo Highlights',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 180,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: promos.length,
|
||||
itemBuilder: (context, index) {
|
||||
final promo = promos[index];
|
||||
return _PromoCard(promo: promo);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PromoCard extends StatelessWidget {
|
||||
final dynamic promo;
|
||||
const _PromoCard({required this.promo});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final base64Img = promo['image_128'] as String?;
|
||||
final title = promo['name'] as String? ?? '';
|
||||
|
||||
Widget imageWidget;
|
||||
if (base64Img != null && base64Img.isNotEmpty) {
|
||||
try {
|
||||
final Uint8List bytes = base64Decode(base64Img);
|
||||
imageWidget = Image.memory(bytes, fit: BoxFit.cover,
|
||||
width: double.infinity, height: 110);
|
||||
} catch (_) {
|
||||
imageWidget = _imagePlaceholder();
|
||||
}
|
||||
} else {
|
||||
imageWidget = _imagePlaceholder();
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => PromoDetailScreen(promo: promo)),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 140,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.surfaceContainerLow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 110,
|
||||
width: double.infinity,
|
||||
child: ClipRect(child: imageWidget),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: AppTheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imagePlaceholder() {
|
||||
return Container(
|
||||
color: AppTheme.surfaceContainer,
|
||||
child: const Center(
|
||||
child: Icon(Icons.local_offer_rounded, size: 32, color: AppTheme.outlineVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
190
lib/widgets/subscription_list_widget.dart
Normal file
190
lib/widgets/subscription_list_widget.dart
Normal file
@ -0,0 +1,190 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Compact "My Subscriptions" list — displayed on the home tab below the loyalty card.
|
||||
/// Each row shows the subscription name, active/expired badge, validity period, and card code.
|
||||
class SubscriptionListWidget extends StatelessWidget {
|
||||
final List<dynamic> subscriptions;
|
||||
|
||||
const SubscriptionListWidget({super.key, required this.subscriptions});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (subscriptions.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
|
||||
child: Text(
|
||||
'MY SUBSCRIPTIONS',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: AppTheme.onSurfaceVariant,
|
||||
letterSpacing: 1.2,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.surfaceContainerLow,
|
||||
),
|
||||
child: Column(
|
||||
children: List.generate(subscriptions.length, (index) {
|
||||
final sub = subscriptions[index];
|
||||
final isLast = index == subscriptions.length - 1;
|
||||
return _SubscriptionTile(sub: sub, isLast: isLast);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SubscriptionTile extends StatelessWidget {
|
||||
final dynamic sub;
|
||||
final bool isLast;
|
||||
|
||||
const _SubscriptionTile({required this.sub, required this.isLast});
|
||||
|
||||
/// Determine active/expired status from subscription_end_date.
|
||||
bool _isActive() {
|
||||
final endRaw = sub['subscription_end_date'];
|
||||
if (endRaw == null || endRaw == false) return true; // no end date = no expiry
|
||||
try {
|
||||
final endDate = DateTime.parse(endRaw.toString());
|
||||
return endDate.isAfter(DateTime.now());
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a date string (YYYY-MM-DD) to a readable label.
|
||||
String _formatDate(dynamic raw) {
|
||||
if (raw == null || raw == false) return '—';
|
||||
try {
|
||||
final dt = DateTime.parse(raw.toString());
|
||||
return '${dt.day.toString().padLeft(2, '0')} '
|
||||
'${_monthName(dt.month)} '
|
||||
'${dt.year}';
|
||||
} catch (_) {
|
||||
return raw.toString();
|
||||
}
|
||||
}
|
||||
|
||||
String _monthName(int m) {
|
||||
const months = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
||||
];
|
||||
return months[m - 1];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final programName = sub['program_id'] is List
|
||||
? (sub['program_id'][1] as String? ?? 'Subscription')
|
||||
: 'Subscription';
|
||||
final code = sub['code'] as String? ?? '';
|
||||
final startDate = _formatDate(sub['subscription_start_date']);
|
||||
final endDate = _formatDate(sub['subscription_end_date']);
|
||||
final active = _isActive();
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: isLast
|
||||
? null
|
||||
: const Border(
|
||||
bottom: BorderSide(
|
||||
color: AppTheme.surfaceContainer,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? AppTheme.secondaryContainer.withValues(alpha: 0.35)
|
||||
: AppTheme.surfaceContainer,
|
||||
shape: BoxShape.rectangle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.verified_rounded,
|
||||
size: 22,
|
||||
color: active ? AppTheme.secondary : AppTheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 14),
|
||||
|
||||
// Name + dates
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
programName,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'$startDate → $endDate',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppTheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (code.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
code,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppTheme.outlineVariant,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Active / Expired badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? const Color(0xFF1B5E20).withValues(alpha: 0.12)
|
||||
: const Color(0xFFB02500).withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
active ? 'ACTIVE' : 'EXPIRED',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.8,
|
||||
color: active
|
||||
? const Color(0xFF2E7D32)
|
||||
: const Color(0xFFB02500),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,12 +7,14 @@ import Foundation
|
||||
|
||||
import app_badge_plus
|
||||
import 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"))
|
||||
}
|
||||
|
||||
104
pubspec.lock
104
pubspec.lock
@ -105,6 +105,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
csslib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: csslib
|
||||
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -145,11 +153,27 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_html:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_html
|
||||
sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -208,6 +232,54 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
geolocator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: geolocator
|
||||
sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.0.4"
|
||||
geolocator_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_android
|
||||
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.6.2"
|
||||
geolocator_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_apple
|
||||
sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.14"
|
||||
geolocator_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_platform_interface
|
||||
sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.8"
|
||||
geolocator_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_web
|
||||
sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.4"
|
||||
geolocator_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_windows
|
||||
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.5"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -232,6 +304,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.15.6"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -296,6 +376,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
list_counter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: list_counter
|
||||
sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -557,6 +645,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
smooth_page_indicator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: smooth_page_indicator
|
||||
sha256: b21ebb8bc39cf72d11c7cfd809162a48c3800668ced1c9da3aade13a32cf6c1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -685,6 +781,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.3"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
geolocator_windows
|
||||
permission_handler_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user