refactor: implement safeString utility for type-safe data access and improve UI formatting across widgets and screens
This commit is contained in:
parent
3bd6eb83ac
commit
4df528272e
@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:geolocator/geolocator.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 '../utils/safe_cast.dart';
|
||||||
|
|
||||||
class BranchesScreen extends StatefulWidget {
|
class BranchesScreen extends StatefulWidget {
|
||||||
const BranchesScreen({super.key});
|
const BranchesScreen({super.key});
|
||||||
|
|
||||||
@ -69,8 +71,11 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
|||||||
} else {
|
} else {
|
||||||
// Fallback: alphabetical sort
|
// Fallback: alphabetical sort
|
||||||
_branches = List<dynamic>.from(branches)
|
_branches = List<dynamic>.from(branches)
|
||||||
..sort((a, b) => (a['name'] as String? ?? '')
|
..sort(
|
||||||
.compareTo(b['name'] as String? ?? ''));
|
(a, b) => (safeString(a['name']) ?? '').compareTo(
|
||||||
|
safeString(b['name']) ?? '',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
@ -79,7 +84,9 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Error loading branches. Check connection.')),
|
const SnackBar(
|
||||||
|
content: Text('Error loading branches. Check connection.'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -94,7 +101,8 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
|||||||
const R = 6371.0;
|
const R = 6371.0;
|
||||||
final dLat = _degToRad(lat - pos.latitude);
|
final dLat = _degToRad(lat - pos.latitude);
|
||||||
final dLng = _degToRad(lng - pos.longitude);
|
final dLng = _degToRad(lng - pos.longitude);
|
||||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
final a =
|
||||||
|
sin(dLat / 2) * sin(dLat / 2) +
|
||||||
cos(_degToRad(pos.latitude)) *
|
cos(_degToRad(pos.latitude)) *
|
||||||
cos(_degToRad(lat)) *
|
cos(_degToRad(lat)) *
|
||||||
sin(dLng / 2) *
|
sin(dLng / 2) *
|
||||||
@ -119,14 +127,16 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
|||||||
|
|
||||||
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) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
const SnackBar(content: Text('Could not open map.')),
|
context,
|
||||||
);
|
).showSnackBar(const SnackBar(content: Text('Could not open map.')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -165,17 +175,24 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
|||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: colorScheme.surfaceContainerLow,
|
color: colorScheme.surfaceContainerLow,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.location_off, size: 16, color: colorScheme.onSurfaceVariant),
|
Icon(
|
||||||
|
Icons.location_off,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Location not available. Showing branches alphabetically.',
|
'Location not available. Showing branches alphabetically.',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -185,16 +202,23 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
|||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: colorScheme.surfaceContainerLow,
|
color: colorScheme.surfaceContainerLow,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.my_location, size: 16, color: colorScheme.secondary),
|
Icon(
|
||||||
|
Icons.my_location,
|
||||||
|
size: 16,
|
||||||
|
color: colorScheme.secondary,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Sorted by distance from your location',
|
'Sorted by distance from your location',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -203,34 +227,56 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
|||||||
// Branch list
|
// Branch list
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _branches.isEmpty
|
child: _branches.isEmpty
|
||||||
? const Center(
|
? ListView(
|
||||||
child: Text('No branches available.', style: TextStyle(fontSize: 16)),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.5,
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'No branches available.',
|
||||||
|
style: TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
itemCount: _branches.length,
|
itemCount: _branches.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final branch = _branches[index];
|
final branch = _branches[index];
|
||||||
|
|
||||||
final street = branch['street'] != null && branch['street'] != false
|
final street =
|
||||||
|
branch['street'] != null &&
|
||||||
|
branch['street'] != false
|
||||||
? branch['street']
|
? branch['street']
|
||||||
: '';
|
: '';
|
||||||
final city = branch['city'] != null && branch['city'] != false
|
final city =
|
||||||
|
branch['city'] != null &&
|
||||||
|
branch['city'] != false
|
||||||
? branch['city']
|
? branch['city']
|
||||||
: '';
|
: '';
|
||||||
final phone = branch['phone'] != null && branch['phone'] != false
|
final phone =
|
||||||
|
branch['phone'] != null &&
|
||||||
|
branch['phone'] != false
|
||||||
? branch['phone']
|
? branch['phone']
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
final addressParts = [street, city]
|
final addressParts = [
|
||||||
.where((e) => e.toString().isNotEmpty)
|
street,
|
||||||
.join(', ');
|
city,
|
||||||
|
].where((e) => e.toString().isNotEmpty).join(', ');
|
||||||
|
|
||||||
final distance = _userPosition != null
|
final distance = _userPosition != null
|
||||||
? _distanceTo(_userPosition!, branch)
|
? _distanceTo(_userPosition!, branch)
|
||||||
: null;
|
: null;
|
||||||
final distanceLabel =
|
final distanceLabel = distance != null
|
||||||
distance != null ? _formatDistance(distance) : '';
|
? _formatDistance(distance)
|
||||||
|
: '';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
@ -248,30 +294,31 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
|||||||
child: ListTile(
|
child: ListTile(
|
||||||
contentPadding: const EdgeInsets.all(16),
|
contentPadding: const EdgeInsets.all(16),
|
||||||
onTap: () => _launchMaps(
|
onTap: () => _launchMaps(
|
||||||
'${branch['name']} $addressParts'),
|
'${branch['name']} $addressParts',
|
||||||
|
),
|
||||||
leading: Container(
|
leading: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.secondaryContainer,
|
color: colorScheme.secondaryContainer,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Icon(Icons.storefront,
|
child: Icon(
|
||||||
color: colorScheme.secondary),
|
Icons.storefront,
|
||||||
|
color: colorScheme.secondary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
branch['name'] ?? 'Mapan Branch',
|
branch['name'] ?? 'Mapan Branch',
|
||||||
style: theme
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
.textTheme
|
fontFamily: 'serif',
|
||||||
.titleMedium
|
fontWeight: FontWeight.bold,
|
||||||
?.copyWith(
|
),
|
||||||
fontFamily: 'serif',
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
subtitle: Padding(
|
subtitle: Padding(
|
||||||
padding: const EdgeInsets.only(top: 6.0),
|
padding: const EdgeInsets.only(top: 6.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (distanceLabel.isNotEmpty) ...[
|
if (distanceLabel.isNotEmpty) ...[
|
||||||
Row(
|
Row(
|
||||||
@ -284,7 +331,8 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
|||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'$distanceLabel away',
|
'$distanceLabel away',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium
|
||||||
|
?.copyWith(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@ -303,31 +351,40 @@ class _BranchesScreenState extends State<BranchesScreen> {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.phone,
|
Icon(
|
||||||
size: 14,
|
Icons.phone,
|
||||||
color: colorScheme.onSurfaceVariant),
|
size: 14,
|
||||||
|
color:
|
||||||
|
colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(phone,
|
Text(
|
||||||
style: theme
|
phone,
|
||||||
.textTheme
|
style: theme.textTheme.bodySmall
|
||||||
.bodySmall
|
?.copyWith(
|
||||||
?.copyWith(
|
color: colorScheme
|
||||||
color: colorScheme.onSurfaceVariant)),
|
.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: phone.isNotEmpty
|
trailing: phone.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
icon: Icon(Icons.chat_bubble,
|
icon: Icon(
|
||||||
color: colorScheme.onSurface),
|
Icons.chat_bubble,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
onPressed: () => _launchWhatsApp(phone),
|
onPressed: () => _launchWhatsApp(phone),
|
||||||
tooltip: 'Chat on WhatsApp',
|
tooltip: 'Chat on WhatsApp',
|
||||||
)
|
)
|
||||||
: Icon(Icons.chevron_right,
|
: Icon(
|
||||||
color: colorScheme.onSurfaceVariant),
|
Icons.chevron_right,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_html/flutter_html.dart';
|
import 'package:flutter_html/flutter_html.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import '../utils/safe_cast.dart';
|
||||||
|
|
||||||
/// Detail screen for a tapped carousel slide.
|
/// Detail screen for a tapped carousel slide.
|
||||||
/// Displays the banner image (base64 or network URL), title, and rich HTML detail content.
|
/// Displays the banner image (base64 or network URL), title, and rich HTML detail content.
|
||||||
@ -12,10 +13,10 @@ class CarouselDetailScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final title = slide['name'] as String? ?? 'Slide Details';
|
final title = safeString(slide['name']) ?? 'Slide Details';
|
||||||
final bodyHtml = slide['body'] as String? ?? '';
|
final bodyHtml = safeString(slide['body']) ?? '';
|
||||||
final base64Img = slide['image'] as String?;
|
final base64Img = safeString(slide['image']);
|
||||||
final externalUrl = slide['image_url'] as String?;
|
final externalUrl = safeString(slide['image_url']);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(title)),
|
appBar: AppBar(title: Text(title)),
|
||||||
@ -28,10 +29,7 @@ class CarouselDetailScreen extends StatelessWidget {
|
|||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||||
child: Text(
|
child: Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||||
title,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
if (bodyHtml.isNotEmpty)
|
if (bodyHtml.isNotEmpty)
|
||||||
@ -116,7 +114,11 @@ class CarouselDetailScreen extends StatelessWidget {
|
|||||||
height: 220,
|
height: 220,
|
||||||
color: AppTheme.surfaceContainer,
|
color: AppTheme.surfaceContainer,
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Icon(Icons.image_rounded, size: 56, color: AppTheme.outlineVariant),
|
child: Icon(
|
||||||
|
Icons.image_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: AppTheme.outlineVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,9 +52,9 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Error loading data: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Error loading data: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -150,15 +150,18 @@ class _LoyaltyCardTile extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
'${card['program_id']?[1] ?? 'Loyalty Program'}',
|
'${card['program_id']?[1] ?? 'Loyalty Program'}',
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
color: onPrimary,
|
color: onPrimary,
|
||||||
fontFamily: 'serif',
|
fontFamily: 'serif',
|
||||||
),
|
),
|
||||||
softWrap: true,
|
softWrap: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: accentColor,
|
color: accentColor,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@ -166,11 +169,13 @@ class _LoyaltyCardTile extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
tier,
|
tier,
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
color: colorScheme.primary.computeLuminance() > 0.5 ? Colors.black : Colors.white,
|
color: colorScheme.primary.computeLuminance() > 0.5
|
||||||
fontWeight: FontWeight.bold,
|
? Colors.black
|
||||||
fontSize: 9,
|
: Colors.white,
|
||||||
letterSpacing: 0.8,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
fontSize: 9,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -179,21 +184,21 @@ class _LoyaltyCardTile extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'MEMBERSHIP CODE',
|
'MEMBERSHIP CODE',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: onPrimary.withValues(alpha: 0.7),
|
color: onPrimary.withValues(alpha: 0.7),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${card['code'] ?? 'N/A'}',
|
'${card['code'] ?? 'N/A'}',
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
color: onPrimary,
|
color: onPrimary,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Row(
|
Row(
|
||||||
@ -206,11 +211,11 @@ class _LoyaltyCardTile extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'AVAILABLE POINTS',
|
'AVAILABLE POINTS',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: onPrimary.withValues(alpha: 0.7),
|
color: onPrimary.withValues(alpha: 0.7),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
letterSpacing: 1.0,
|
letterSpacing: 1.0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Row(
|
Row(
|
||||||
@ -224,20 +229,29 @@ class _LoyaltyCardTile extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Dine & Save',
|
'Dine & Save',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: onPrimary.withValues(alpha: 0.9),
|
color: onPrimary.withValues(alpha: 0.9),
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Text(
|
const SizedBox(width: 16),
|
||||||
'${card['points'] ?? 0}',
|
Expanded(
|
||||||
style: theme.textTheme.displayLarge?.copyWith(
|
child: Align(
|
||||||
color: accentColor,
|
alignment: Alignment.bottomRight,
|
||||||
fontWeight: FontWeight.bold,
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
'${card['points'] ?? 0}',
|
||||||
|
style: theme.textTheme.displayMedium?.copyWith(
|
||||||
|
color: accentColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_html/flutter_html.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';
|
||||||
|
import '../utils/safe_cast.dart';
|
||||||
|
|
||||||
class NotificationDetailScreen extends StatelessWidget {
|
class NotificationDetailScreen extends StatelessWidget {
|
||||||
final dynamic notif;
|
final dynamic notif;
|
||||||
@ -10,13 +11,11 @@ class NotificationDetailScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final title = notif['title'] as String? ?? 'Notice';
|
final title = safeString(notif['title']) ?? 'Notice';
|
||||||
final bodyHtml = notif['body'] as String? ?? '';
|
final bodyHtml = safeString(notif['body']) ?? '';
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('Notification Detail')),
|
||||||
title: const Text('Notification Detail'),
|
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@ -44,9 +43,9 @@ class NotificationDetailScreen extends StatelessWidget {
|
|||||||
title,
|
title,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
color: AppTheme.onSurface,
|
color: AppTheme.onSurface,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -75,8 +74,11 @@ class NotificationDetailScreen extends StatelessWidget {
|
|||||||
height: 150,
|
height: 150,
|
||||||
color: AppTheme.surfaceContainerLow,
|
color: AppTheme.surfaceContainerLow,
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Icon(Icons.broken_image_outlined,
|
child: Icon(
|
||||||
size: 48, color: AppTheme.onSurfaceVariant),
|
Icons.broken_image_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: AppTheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -93,9 +95,9 @@ class NotificationDetailScreen extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
'MESSAGE',
|
'MESSAGE',
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
color: AppTheme.onSurfaceVariant,
|
color: AppTheme.onSurfaceVariant,
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@ -116,9 +118,9 @@ class NotificationDetailScreen extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
'No additional details.',
|
'No additional details.',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: AppTheme.onSurfaceVariant,
|
color: AppTheme.onSurfaceVariant,
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Padding(
|
: Padding(
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import '../services/odoo_service.dart';
|
|||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'notification_detail_screen.dart';
|
import 'notification_detail_screen.dart';
|
||||||
|
import '../utils/safe_cast.dart';
|
||||||
|
|
||||||
class NotificationsScreen extends StatefulWidget {
|
class NotificationsScreen extends StatefulWidget {
|
||||||
const NotificationsScreen({super.key});
|
const NotificationsScreen({super.key});
|
||||||
@ -67,71 +68,98 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
|||||||
appBar: AppBar(title: const Text('Notifications')),
|
appBar: AppBar(title: const Text('Notifications')),
|
||||||
body: _isLoading
|
body: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
: _notifications.isEmpty
|
: RefreshIndicator(
|
||||||
? Center(
|
onRefresh: _fetchNotifications,
|
||||||
child: Column(
|
child: _notifications.isEmpty
|
||||||
mainAxisSize: MainAxisSize.min,
|
? CustomScrollView(
|
||||||
children: [
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
Icon(
|
slivers: [
|
||||||
Icons.notifications_none,
|
SliverFillRemaining(
|
||||||
size: 64,
|
hasScrollBody: false,
|
||||||
color: AppTheme.onSurfaceVariant.withValues(alpha: 0.4),
|
child: Center(
|
||||||
),
|
child: Column(
|
||||||
const SizedBox(height: 16),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Text(
|
children: [
|
||||||
'No notifications yet.',
|
Icon(
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
Icons.notifications_none,
|
||||||
color: AppTheme.onSurfaceVariant,
|
size: 64,
|
||||||
|
color: AppTheme.onSurfaceVariant.withValues(
|
||||||
|
alpha: 0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'No notifications yet.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge
|
||||||
|
?.copyWith(
|
||||||
|
color: AppTheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 20,
|
||||||
),
|
),
|
||||||
],
|
itemCount: _notifications.length,
|
||||||
),
|
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||||
)
|
itemBuilder: (context, index) {
|
||||||
: RefreshIndicator(
|
final notif = _notifications[index];
|
||||||
onRefresh: _fetchNotifications,
|
final isUnread = !_readIds.contains(
|
||||||
child: ListView.separated(
|
(notif['id'] as int? ?? 0).toString(),
|
||||||
padding: const EdgeInsets.symmetric(
|
);
|
||||||
horizontal: 16, vertical: 20),
|
return _NotificationCard(
|
||||||
itemCount: _notifications.length,
|
notif: notif,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
isUnread: isUnread,
|
||||||
itemBuilder: (context, index) {
|
onTap: () async {
|
||||||
final notif = _notifications[index];
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final isUnread = !_readIds.contains((notif['id'] as int? ?? 0).toString());
|
final partnerId =
|
||||||
return _NotificationCard(
|
OdooService().client?.sessionId?.partnerId ?? 0;
|
||||||
notif: notif,
|
final keyReadNotificationIds =
|
||||||
isUnread: isUnread,
|
'read_notification_ids_$partnerId';
|
||||||
onTap: () async {
|
final readIds =
|
||||||
final prefs = await SharedPreferences.getInstance();
|
prefs.getStringList(keyReadNotificationIds) ??
|
||||||
final partnerId = OdooService().client?.sessionId?.partnerId ?? 0;
|
[];
|
||||||
final keyReadNotificationIds = 'read_notification_ids_$partnerId';
|
final notifIdStr = (notif['id'] as int? ?? 0)
|
||||||
final readIds = prefs.getStringList(keyReadNotificationIds) ?? [];
|
.toString();
|
||||||
final notifIdStr = (notif['id'] as int? ?? 0).toString();
|
if (!readIds.contains(notifIdStr)) {
|
||||||
if (!readIds.contains(notifIdStr)) {
|
readIds.add(notifIdStr);
|
||||||
readIds.add(notifIdStr);
|
await prefs.setStringList(
|
||||||
await prefs.setStringList(keyReadNotificationIds, readIds);
|
keyReadNotificationIds,
|
||||||
|
readIds,
|
||||||
|
);
|
||||||
|
|
||||||
// Recalculate and update system badge count
|
// Recalculate and update system badge count
|
||||||
final unreadCount = _notifications
|
final unreadCount = _notifications
|
||||||
.where((n) => !readIds.contains((n['id'] as int? ?? 0).toString()))
|
.where(
|
||||||
.length;
|
(n) => !readIds.contains(
|
||||||
await NotificationService().setBadge(unreadCount);
|
(n['id'] as int? ?? 0).toString(),
|
||||||
}
|
),
|
||||||
|
)
|
||||||
|
.length;
|
||||||
|
await NotificationService().setBadge(unreadCount);
|
||||||
|
}
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) =>
|
builder: (_) =>
|
||||||
NotificationDetailScreen(notif: notif),
|
NotificationDetailScreen(notif: notif),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
_fetchNotifications();
|
_fetchNotifications();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -149,7 +177,7 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final title = notif['title'] as String? ?? 'Notice';
|
final title = safeString(notif['title']) ?? 'Notice';
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: AppTheme.surfaceContainerLow,
|
color: AppTheme.surfaceContainerLow,
|
||||||
@ -191,9 +219,9 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
color: AppTheme.onSurface,
|
color: AppTheme.onSurface,
|
||||||
fontWeight: isUnread ? FontWeight.bold : FontWeight.normal,
|
fontWeight: isUnread ? FontWeight.bold : FontWeight.normal,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/odoo_service.dart';
|
import '../services/odoo_service.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import '../utils/safe_cast.dart';
|
||||||
|
|
||||||
class OrdersScreen extends StatefulWidget {
|
class OrdersScreen extends StatefulWidget {
|
||||||
const OrdersScreen({super.key});
|
const OrdersScreen({super.key});
|
||||||
@ -32,9 +33,9 @@ class _OrdersScreenState extends State<OrdersScreen> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Error loading history: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Error loading history: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -44,8 +45,18 @@ class _OrdersScreenState extends State<OrdersScreen> {
|
|||||||
try {
|
try {
|
||||||
final parsed = DateTime.parse(rawDate);
|
final parsed = DateTime.parse(rawDate);
|
||||||
final months = [
|
final months = [
|
||||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
'Jan',
|
||||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
];
|
];
|
||||||
final year = parsed.year;
|
final year = parsed.year;
|
||||||
final month = months[parsed.month - 1];
|
final month = months[parsed.month - 1];
|
||||||
@ -63,73 +74,89 @@ class _OrdersScreenState extends State<OrdersScreen> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
return const Scaffold(
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
body: Center(child: CircularProgressIndicator()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(title: const Text('Order & Points History')),
|
||||||
title: const Text('Order & Points History'),
|
body: RefreshIndicator(
|
||||||
),
|
onRefresh: _fetchHistory,
|
||||||
body: _history.isEmpty
|
child: _history.isEmpty
|
||||||
? Center(
|
? CustomScrollView(
|
||||||
child: Padding(
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(40),
|
slivers: [
|
||||||
child: Column(
|
SliverFillRemaining(
|
||||||
mainAxisSize: MainAxisSize.min,
|
hasScrollBody: false,
|
||||||
children: [
|
child: Center(
|
||||||
Container(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(28),
|
padding: const EdgeInsets.all(40),
|
||||||
decoration: const BoxDecoration(
|
child: Column(
|
||||||
color: AppTheme.surfaceContainerLow,
|
mainAxisSize: MainAxisSize.min,
|
||||||
shape: BoxShape.circle,
|
children: [
|
||||||
),
|
Container(
|
||||||
child: const Icon(
|
padding: const EdgeInsets.all(28),
|
||||||
Icons.receipt_long_rounded,
|
decoration: const BoxDecoration(
|
||||||
size: 56,
|
color: AppTheme.surfaceContainerLow,
|
||||||
color: AppTheme.secondary,
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.receipt_long_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: AppTheme.secondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
Text(
|
||||||
|
'No Orders Yet',
|
||||||
|
style: theme.textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Your order history and points transactions will show up here after you make purchases.',
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: AppTheme.onSurfaceVariant,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
),
|
||||||
Text(
|
],
|
||||||
'No Orders Yet',
|
)
|
||||||
style: theme.textTheme.headlineMedium,
|
: ListView.builder(
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
'Your order history and points transactions will show up here after you make purchases.',
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: AppTheme.onSurfaceVariant,
|
|
||||||
height: 1.6,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: RefreshIndicator(
|
|
||||||
onRefresh: _fetchHistory,
|
|
||||||
child: ListView.builder(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
itemCount: _history.length,
|
itemCount: _history.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = _history[index];
|
final item = _history[index];
|
||||||
final isEarn = item['type'] == 'earn';
|
final isEarn = item['type'] == 'earn';
|
||||||
final orderRef = item['order_ref'] as String? ?? '';
|
final orderRef = safeString(item['order_ref']) ?? '';
|
||||||
final rawDate = item['date'] as String? ?? '';
|
final rawDate = safeString(item['date']) ?? '';
|
||||||
final posName = item['pos_name'] as String? ?? '';
|
final posName = safeString(item['pos_name']) ?? '';
|
||||||
|
final programType = safeString(item['program_type']) ?? 'loyalty';
|
||||||
|
final isSubscription = programType == 'subscription';
|
||||||
|
final programName = safeString(item['program_name']) ?? '';
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
color: AppTheme.surfaceContainerLow,
|
color: AppTheme.surfaceContainerLow,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
leading: Container(
|
leading: Container(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: (isEarn ? const Color(0xFF2E7D32) : const Color(0xFFC62828)).withValues(alpha: 0.1),
|
color: (isEarn
|
||||||
|
? const Color(0xFF2E7D32)
|
||||||
|
: const Color(0xFFC62828))
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@ -176,6 +203,52 @@ class _OrdersScreenState extends State<OrdersScreen> {
|
|||||||
color: AppTheme.onSurfaceVariant,
|
color: AppTheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSubscription
|
||||||
|
? const Color(0xFF1B5E20).withValues(alpha: 0.08)
|
||||||
|
: theme.colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSubscription
|
||||||
|
? const Color(0xFF1B5E20).withValues(alpha: 0.2)
|
||||||
|
: theme.colorScheme.primary.withValues(alpha: 0.2),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isSubscription
|
||||||
|
? Icons.local_activity_rounded
|
||||||
|
: Icons.card_membership_rounded,
|
||||||
|
size: 10,
|
||||||
|
color: isSubscription
|
||||||
|
? const Color(0xFF2E7D32)
|
||||||
|
: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
isSubscription
|
||||||
|
? 'Subscription Claim'
|
||||||
|
: (programName.isNotEmpty
|
||||||
|
? programName
|
||||||
|
: 'Loyalty Program'),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: isSubscription
|
||||||
|
? const Color(0xFF2E7D32)
|
||||||
|
: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -191,7 +264,7 @@ class _OrdersScreenState extends State<OrdersScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_html/flutter_html.dart';
|
import 'package:flutter_html/flutter_html.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
|
import '../utils/safe_cast.dart';
|
||||||
|
|
||||||
/// Promo detail screen — mirrors notification detail but for promo highlights.
|
/// Promo detail screen — mirrors notification detail but for promo highlights.
|
||||||
/// Shows the promo image (full size), title, and rich HTML body content.
|
/// Shows the promo image (full size), title, and rich HTML body content.
|
||||||
@ -12,9 +13,9 @@ class PromoDetailScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final title = promo['name'] as String? ?? 'Promo';
|
final title = safeString(promo['name']) ?? 'Promo';
|
||||||
final bodyHtml = promo['body'] as String? ?? '';
|
final bodyHtml = safeString(promo['body']) ?? '';
|
||||||
final base64Img = promo['image_128'] as String?;
|
final base64Img = safeString(promo['image_128']);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(title)),
|
appBar: AppBar(title: Text(title)),
|
||||||
@ -28,10 +29,7 @@ class PromoDetailScreen extends StatelessWidget {
|
|||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||||
child: Text(
|
child: Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||||
title,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
if (bodyHtml.isNotEmpty)
|
if (bodyHtml.isNotEmpty)
|
||||||
@ -91,7 +89,11 @@ class PromoDetailScreen extends StatelessWidget {
|
|||||||
height: 220,
|
height: 220,
|
||||||
color: AppTheme.surfaceContainer,
|
color: AppTheme.surfaceContainer,
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Icon(Icons.local_offer_rounded, size: 56, color: AppTheme.outlineVariant),
|
child: Icon(
|
||||||
|
Icons.local_offer_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: AppTheme.outlineVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
class AppConfig {
|
class AppConfig {
|
||||||
static const String odooUrl =
|
static const String odooUrl =
|
||||||
'https://odoodev.mapan.co.id'; // Default local dev url
|
'https://odoodev.mapan.co.id'; // Default local dev url
|
||||||
static const String odooDb = 'mapangroup_o19_260605'; // Default local dev db
|
static const String odooDb = 'mapangroup_o19_260615'; // Default local dev db
|
||||||
}
|
}
|
||||||
|
|||||||
9
lib/utils/safe_cast.dart
Normal file
9
lib/utils/safe_cast.dart
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/// Safely converts a dynamic value from Odoo JSON-RPC to a String.
|
||||||
|
/// Odoo returns `false` (boolean) for unset fields, which causes standard
|
||||||
|
/// Dart type casts (like `value as String?`) to crash.
|
||||||
|
String? safeString(dynamic value) {
|
||||||
|
if (value is String) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
|
import 'package:smooth_page_indicator/smooth_page_indicator.dart';
|
||||||
import '../screens/carousel_detail_screen.dart';
|
import '../screens/carousel_detail_screen.dart';
|
||||||
|
import '../utils/safe_cast.dart';
|
||||||
|
|
||||||
/// Auto-scrolling carousel widget that shows slides from CMS.
|
/// Auto-scrolling carousel widget that shows slides from CMS.
|
||||||
/// Each slide can have an uploaded image (base64) or external image URL.
|
/// Each slide can have an uploaded image (base64) or external image URL.
|
||||||
@ -102,8 +103,8 @@ class _SlideImage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final base64Img = slide['image'] as String?;
|
final base64Img = safeString(slide['image']);
|
||||||
final externalUrl = slide['image_url'] as String?;
|
final externalUrl = safeString(slide['image_url']);
|
||||||
|
|
||||||
Widget image;
|
Widget image;
|
||||||
|
|
||||||
@ -144,10 +145,7 @@ class _SlideImage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(borderRadius: BorderRadius.circular(16), child: image),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: image,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../screens/promo_detail_screen.dart';
|
import '../screens/promo_detail_screen.dart';
|
||||||
|
import '../utils/safe_cast.dart';
|
||||||
|
|
||||||
/// Horizontal scrollable row of promo highlight cards.
|
/// Horizontal scrollable row of promo highlight cards.
|
||||||
/// Tapping a card opens the full detail screen with rich text content.
|
/// Tapping a card opens the full detail screen with rich text content.
|
||||||
@ -49,15 +50,19 @@ class _PromoCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final base64Img = promo['image_128'] as String?;
|
final base64Img = safeString(promo['image_128']);
|
||||||
final title = promo['name'] as String? ?? '';
|
final title = safeString(promo['name']) ?? '';
|
||||||
|
|
||||||
Widget imageWidget;
|
Widget imageWidget;
|
||||||
if (base64Img != null && base64Img.isNotEmpty) {
|
if (base64Img != null && base64Img.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
final Uint8List bytes = base64Decode(base64Img);
|
final Uint8List bytes = base64Decode(base64Img);
|
||||||
imageWidget = Image.memory(bytes, fit: BoxFit.cover,
|
imageWidget = Image.memory(
|
||||||
width: double.infinity, height: 110);
|
bytes,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: double.infinity,
|
||||||
|
height: 110,
|
||||||
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
imageWidget = _imagePlaceholder(colorScheme);
|
imageWidget = _imagePlaceholder(colorScheme);
|
||||||
}
|
}
|
||||||
@ -97,7 +102,9 @@ class _PromoCard extends StatelessWidget {
|
|||||||
height: 110,
|
height: 110,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(15),
|
||||||
|
),
|
||||||
child: imageWidget,
|
child: imageWidget,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -124,7 +131,11 @@ class _PromoCard extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
color: colorScheme.surfaceContainer,
|
color: colorScheme.surfaceContainer,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(Icons.local_offer_rounded, size: 32, color: colorScheme.outline),
|
child: Icon(
|
||||||
|
Icons.local_offer_rounded,
|
||||||
|
size: 32,
|
||||||
|
color: colorScheme.outline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import '../utils/safe_cast.dart';
|
||||||
|
|
||||||
/// "My Subscriptions" list displayed on the home tab below the loyalty card.
|
/// "My Subscriptions" list displayed on the home tab below the loyalty card.
|
||||||
/// Renders each subscription as a beautiful standalone ticket card visually distinct
|
/// Renders each subscription as a beautiful standalone ticket card visually distinct
|
||||||
@ -23,11 +24,11 @@ class SubscriptionListWidget extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
'MY SUBSCRIPTIONS',
|
'MY SUBSCRIPTIONS',
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
...subscriptions.map((sub) => _SubscriptionCard(sub: sub)),
|
...subscriptions.map((sub) => _SubscriptionCard(sub: sub)),
|
||||||
@ -68,8 +69,18 @@ class _SubscriptionCard extends StatelessWidget {
|
|||||||
|
|
||||||
String _monthName(int m) {
|
String _monthName(int m) {
|
||||||
const months = [
|
const months = [
|
||||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
'Jan',
|
||||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
];
|
];
|
||||||
return months[m - 1];
|
return months[m - 1];
|
||||||
}
|
}
|
||||||
@ -80,9 +91,9 @@ class _SubscriptionCard extends StatelessWidget {
|
|||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
final programName = sub['program_id'] is List
|
final programName = sub['program_id'] is List
|
||||||
? (sub['program_id'][1] as String? ?? 'Subscription')
|
? (safeString(sub['program_id'][1]) ?? 'Subscription')
|
||||||
: 'Subscription';
|
: 'Subscription';
|
||||||
final code = sub['code'] as String? ?? '';
|
final code = safeString(sub['code']) ?? '';
|
||||||
final startDate = _formatDate(sub['subscription_start_date']);
|
final startDate = _formatDate(sub['subscription_start_date']);
|
||||||
final endDate = _formatDate(sub['subscription_end_date']);
|
final endDate = _formatDate(sub['subscription_end_date']);
|
||||||
final active = _isActive();
|
final active = _isActive();
|
||||||
@ -106,24 +117,32 @@ class _SubscriptionCard extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.local_activity_rounded, // Distinct ticket pass icon
|
Icons
|
||||||
|
.local_activity_rounded, // Distinct ticket pass icon
|
||||||
size: 18,
|
size: 18,
|
||||||
color: active ? colorScheme.primary : colorScheme.outline,
|
color: active
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.outline,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'SUBSCRIPTION PASS', // Distinct title
|
'SUBSCRIPTION PASS', // Distinct title
|
||||||
style: theme.textTheme.labelLarge?.copyWith(
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
color: active ? colorScheme.primary : colorScheme.outline,
|
color: active
|
||||||
fontWeight: FontWeight.bold,
|
? colorScheme.primary
|
||||||
fontSize: 10,
|
: colorScheme.outline,
|
||||||
letterSpacing: 1.2,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
fontSize: 10,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: active
|
color: active
|
||||||
? const Color(0xFF1B5E20).withValues(alpha: 0.10)
|
? const Color(0xFF1B5E20).withValues(alpha: 0.10)
|
||||||
@ -157,10 +176,10 @@ class _SubscriptionCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
programName,
|
programName,
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
fontFamily: 'serif',
|
fontFamily: 'serif',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
@ -172,18 +191,18 @@ class _SubscriptionCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Pass Code', // Distinct field label
|
'Pass Code', // Distinct field label
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
code.isNotEmpty ? code : 'N/A',
|
code.isNotEmpty ? code : 'N/A',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -194,23 +213,24 @@ class _SubscriptionCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Claim Balance',
|
'Claim Balance',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'${(sub['points'] as num).toDouble() % 1 == 0 ? (sub['points'] as num).toInt() : sub['points']} Claims',
|
'${(sub['points'] as num).toDouble() % 1 == 0 ? (sub['points'] as num).toInt() : sub['points']} Claims',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (sub['subscription_start_date'] != null && sub['subscription_start_date'] != false) ...[
|
if (sub['subscription_start_date'] != null &&
|
||||||
|
sub['subscription_start_date'] != false) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@ -221,17 +241,17 @@ class _SubscriptionCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Valid From',
|
'Valid From',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
startDate,
|
startDate,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -241,17 +261,17 @@ class _SubscriptionCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'Expires On',
|
'Expires On',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
endDate,
|
endDate,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -273,7 +293,7 @@ class TicketClipper extends CustomClipper<Path> {
|
|||||||
final path = Path();
|
final path = Path();
|
||||||
path.lineTo(0, 0);
|
path.lineTo(0, 0);
|
||||||
path.lineTo(size.width, 0);
|
path.lineTo(size.width, 0);
|
||||||
|
|
||||||
// Right side notch at height 49 (approx. center of vertical separator space)
|
// Right side notch at height 49 (approx. center of vertical separator space)
|
||||||
const double notchY = 49.0;
|
const double notchY = 49.0;
|
||||||
const double notchRadius = 8.0;
|
const double notchRadius = 8.0;
|
||||||
@ -285,7 +305,7 @@ class TicketClipper extends CustomClipper<Path> {
|
|||||||
);
|
);
|
||||||
path.lineTo(size.width, size.height);
|
path.lineTo(size.width, size.height);
|
||||||
path.lineTo(0, size.height);
|
path.lineTo(0, size.height);
|
||||||
|
|
||||||
// Left side notch
|
// Left side notch
|
||||||
path.lineTo(0, notchY + notchRadius);
|
path.lineTo(0, notchY + notchRadius);
|
||||||
path.arcToPoint(
|
path.arcToPoint(
|
||||||
@ -317,11 +337,7 @@ class DashedLinePainter extends CustomPainter {
|
|||||||
..strokeWidth = 1.0;
|
..strokeWidth = 1.0;
|
||||||
|
|
||||||
while (startX < size.width) {
|
while (startX < size.width) {
|
||||||
canvas.drawLine(
|
canvas.drawLine(Offset(startX, 0), Offset(startX + dashWidth, 0), paint);
|
||||||
Offset(startX, 0),
|
|
||||||
Offset(startX + dashWidth, 0),
|
|
||||||
paint,
|
|
||||||
);
|
|
||||||
startX += dashWidth + dashSpace;
|
startX += dashWidth + dashSpace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user