diff --git a/lib/screens/loyalty_dashboard.dart b/lib/screens/loyalty_dashboard.dart index f10e23f..5998327 100644 --- a/lib/screens/loyalty_dashboard.dart +++ b/lib/screens/loyalty_dashboard.dart @@ -130,139 +130,230 @@ class _LoyaltyCardTile extends StatelessWidget { return Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 8), - padding: const EdgeInsets.all(24), decoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.primary, + Color.lerp(colorScheme.primary, Colors.black, 0.22) ?? colorScheme.primary, + ], + ), border: Border.all( - color: accentColor.withValues(alpha: 0.5), + color: accentColor.withValues(alpha: 0.45), width: 1.5, ), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.12), - blurRadius: 16, - offset: const Offset(0, 6), + color: Colors.black.withValues(alpha: 0.16), + blurRadius: 18, + offset: const Offset(0, 8), ), ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - '${card['program_id']?[1] ?? 'Loyalty Program'}', - style: theme.textTheme.titleLarge?.copyWith( - color: onPrimary, - fontFamily: 'serif', - ), - softWrap: true, - ), - ), - const SizedBox(width: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Stack( + children: [ + // Decorative background patterns + Positioned( + right: -40, + top: -40, + child: Container( + width: 180, + height: 180, decoration: BoxDecoration( - color: accentColor, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - tier, - style: theme.textTheme.labelLarge?.copyWith( - color: colorScheme.primary.computeLuminance() > 0.5 - ? Colors.black - : Colors.white, - fontWeight: FontWeight.bold, - fontSize: 9, - letterSpacing: 0.8, - ), + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.04), ), ), - ], - ), - const SizedBox(height: 24), - Text( - 'MEMBERSHIP CODE', - style: theme.textTheme.bodySmall?.copyWith( - color: onPrimary.withValues(alpha: 0.7), - fontWeight: FontWeight.bold, - fontSize: 10, - letterSpacing: 1.0, ), - ), - const SizedBox(height: 4), - Text( - '${card['code'] ?? 'N/A'}', - style: theme.textTheme.titleMedium?.copyWith( - color: onPrimary, - fontFamily: 'monospace', - fontSize: 16, - fontWeight: FontWeight.bold, + Positioned( + right: 20, + bottom: -80, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.03), + ), + ), ), - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Column( + Positioned( + left: -30, + bottom: -40, + child: Container( + width: 130, + height: 130, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withValues(alpha: 0.06), + ), + ), + ), + // Card Content + Padding( + padding: const EdgeInsets.all(24), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Top Row: Logo / Star + Tier Badge + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + Icons.stars_rounded, + color: accentColor, + size: 22, + ), + const SizedBox(width: 8), + Text( + 'MAPAN CLUB', + style: theme.textTheme.labelMedium?.copyWith( + color: onPrimary.withValues(alpha: 0.85), + fontWeight: FontWeight.w900, + letterSpacing: 2.0, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: accentColor, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + tier, + style: theme.textTheme.labelLarge?.copyWith( + color: colorScheme.primary.computeLuminance() > 0.5 + ? Colors.black + : Colors.white, + fontWeight: FontWeight.bold, + fontSize: 9, + letterSpacing: 0.8, + ), + ), + ), + ], + ), + const SizedBox(height: 36), + + // Middle Section: Card Label Text( - 'AVAILABLE POINTS', - style: theme.textTheme.bodySmall?.copyWith( - color: onPrimary.withValues(alpha: 0.7), + 'MEMBER ID', + style: theme.textTheme.labelSmall?.copyWith( + color: onPrimary.withValues(alpha: 0.6), fontWeight: FontWeight.bold, - fontSize: 10, - letterSpacing: 1.0, + letterSpacing: 1.5, ), ), - const SizedBox(height: 4), + const SizedBox(height: 8), + + // Card Number (Membership Code) + Text( + '${card['code'] ?? 'N/A'}', + style: theme.textTheme.titleMedium?.copyWith( + color: onPrimary, + fontFamily: 'monospace', + fontSize: 16, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues(alpha: 0.25), + offset: const Offset(1, 1), + blurRadius: 2, + ), + ], + ), + ), + const SizedBox(height: 24), + + // Divider line + Container( + height: 1, + color: onPrimary.withValues(alpha: 0.12), + ), + const SizedBox(height: 16), + + // Bottom Section: Points Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, children: [ - Icon( - Icons.restaurant_rounded, - color: accentColor, - size: 16, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AVAILABLE POINTS', + style: theme.textTheme.bodySmall?.copyWith( + color: onPrimary.withValues(alpha: 0.6), + fontWeight: FontWeight.bold, + fontSize: 9, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Icon( + Icons.restaurant_rounded, + color: accentColor, + size: 14, + ), + const SizedBox(width: 6), + Text( + 'Dine & Save', + style: theme.textTheme.bodyMedium?.copyWith( + color: onPrimary.withValues(alpha: 0.8), + fontStyle: FontStyle.italic, + fontSize: 12, + ), + ), + ], + ), + ], ), - const SizedBox(width: 6), - Text( - 'Dine & Save', - style: theme.textTheme.bodyMedium?.copyWith( - color: onPrimary.withValues(alpha: 0.9), - fontStyle: FontStyle.italic, + Expanded( + child: Align( + alignment: Alignment.bottomRight, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '${card['points'] ?? 0} pts', + style: theme.textTheme.headlineLarge?.copyWith( + color: accentColor, + fontWeight: FontWeight.w900, + fontSize: 32, + ), + ), + ), ), ), ], ), ], ), - const SizedBox(width: 16), - Expanded( - child: Align( - alignment: Alignment.bottomRight, - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - '${card['points'] ?? 0}', - style: theme.textTheme.displayMedium?.copyWith( - color: accentColor, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ], - ), - ], + ), + ], + ), ), ); } } + + diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index debe62e..941136e 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -10,6 +10,7 @@ import 'loyalty_dashboard.dart'; import 'branches_screen.dart'; import 'orders_screen.dart'; import 'account_screen.dart'; +import 'rewards_screen.dart'; class MainShell extends StatefulWidget { final int partnerId; @@ -31,6 +32,7 @@ class _MainShellState extends State { super.initState(); _pages = [ LoyaltyDashboard(partnerId: widget.partnerId), + RewardsScreen(partnerId: widget.partnerId), const BranchesScreen(), const OrdersScreen(), const AccountScreen(), @@ -120,9 +122,10 @@ class _MainShellState extends State { @override Widget build(BuildContext context) { - final navLabels = ['Home', 'Branches', 'Orders', 'Account']; + final navLabels = ['Home', 'Rewards', 'Branches', 'History', 'Account']; final navIcons = [ Icons.home_rounded, + Icons.redeem_rounded, Icons.location_on_rounded, Icons.receipt_long_rounded, Icons.person_rounded, @@ -210,7 +213,7 @@ class _MainShellState extends State { }, backgroundColor: colorScheme.surfaceContainerLowest, indicatorColor: colorScheme.primary, - destinations: List.generate(4, (i) { + destinations: List.generate(5, (i) { return NavigationDestination( icon: Icon(navIcons[i], color: i == _currentIndex diff --git a/lib/screens/rewards_screen.dart b/lib/screens/rewards_screen.dart new file mode 100644 index 0000000..7894b0a --- /dev/null +++ b/lib/screens/rewards_screen.dart @@ -0,0 +1,416 @@ +import 'package:flutter/material.dart'; +import '../services/odoo_service.dart'; +import '../utils/safe_cast.dart'; + +class RewardsScreen extends StatefulWidget { + final int partnerId; + const RewardsScreen({super.key, required this.partnerId}); + + @override + State createState() => _RewardsScreenState(); +} + +class _RewardsScreenState extends State { + double _userPoints = 0.0; + List _rewards = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _fetchData(); + } + + Future _fetchData() async { + if (!mounted) return; + setState(() => _isLoading = true); + + try { + final cards = await OdooService().getLoyaltyCards(widget.partnerId); + + double totalPoints = 0.0; + List rawRewards = []; + + if (cards.isNotEmpty) { + totalPoints = safeDouble(cards.first['points']); + final prog = cards.first['program_id']; + int? programId; + if (prog is List && prog.isNotEmpty) { + programId = prog[0] as int?; + } else if (prog is int) { + programId = prog; + } + + if (programId != null) { + rawRewards = await OdooService().getLoyaltyRewards(programId); + } + } + + if (mounted) { + setState(() { + _userPoints = totalPoints; + _rewards = rawRewards; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() => _isLoading = false); + final errStr = e.toString().toLowerCase(); + final isSessionExpired = e.runtimeType.toString().contains('SessionExpired') || + errStr.contains('session expired') || + errStr.contains('session_expired'); + if (!isSessionExpired) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to load rewards. Please try again.')), + ); + } + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return Scaffold( + backgroundColor: Colors.transparent, // transparency allows MainShell gradient to show + body: RefreshIndicator( + onRefresh: _fetchData, + child: Column( + children: [ + // Top Balance Banner + Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 8), + padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colorScheme.primary, + colorScheme.primary.withValues(alpha: 0.85), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.secondary.withValues(alpha: 0.4), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.15), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Points Balance', + style: theme.textTheme.labelMedium?.copyWith( + color: colorScheme.onPrimary.withValues(alpha: 0.8), + letterSpacing: 1.0, + ), + ), + const SizedBox(height: 4), + Text( + '${_userPoints.toStringAsFixed(0)} Points', + style: theme.textTheme.headlineMedium?.copyWith( + color: colorScheme.onPrimary, + fontWeight: FontWeight.bold, + fontFamily: 'Lora', + ), + ), + ], + ), + Icon( + Icons.stars_rounded, + color: colorScheme.secondary, + size: 44, + ), + ], + ), + ), + + // Instruction Banner + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + size: 16, + color: colorScheme.primary.withValues(alpha: 0.75), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Present your Member ID barcode or Phone Number to the cashier to claim these rewards.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.textTheme.bodyMedium?.color?.withValues(alpha: 0.75), + height: 1.3, + ), + ), + ), + ], + ), + ), + + // Rewards List + Expanded( + child: _rewards.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.redeem_rounded, + size: 64, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.3), + ), + const SizedBox(height: 16), + Text( + 'No rewards currently available', + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + itemCount: _rewards.length, + itemBuilder: (context, index) { + final reward = _rewards[index]; + final reqPoints = safeDouble(reward['required_points']); + final isAvailable = _userPoints >= reqPoints; + final String desc = safeString(reward['description']) ?? 'Loyalty Reward'; + final String type = safeString(reward['reward_type']) ?? 'product'; + + // Decide icon based on reward type + IconData iconData = Icons.local_offer_rounded; + if (type == 'product') { + iconData = Icons.local_dining_rounded; + } else if (type == 'shipping') { + iconData = Icons.local_shipping_rounded; + } + + return Container( + margin: const EdgeInsets.only(bottom: 14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.15), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Left Points Ticket Column + Container( + width: 90, + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.06), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(15), + bottomLeft: Radius.circular(15), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + reqPoints.toStringAsFixed(0), + style: theme.textTheme.titleLarge?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 2), + Text( + 'PTS', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.primary.withValues(alpha: 0.8), + fontWeight: FontWeight.w900, + letterSpacing: 1.0, + ), + ), + ], + ), + ), + + // Custom Dashed Divider (Simulating Tear-off Voucher) + CustomPaint( + size: const Size(1, double.infinity), + painter: _TicketDividerPainter( + color: colorScheme.outline.withValues(alpha: 0.25), + ), + ), + + // Right Content Column + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + iconData, + size: 18, + color: colorScheme.secondary, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + desc, + style: theme.textTheme.titleMedium?.copyWith( + fontFamily: 'Lora', + fontWeight: FontWeight.bold, + height: 1.2, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + // Status indicator + isAvailable + ? Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.green.withValues(alpha: 0.25), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.check_circle_rounded, + size: 12, + color: Colors.green, + ), + const SizedBox(width: 4), + Text( + 'Available to Redeem', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.green[800], + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ], + ), + ) + : Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: colorScheme.error.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.error.withValues(alpha: 0.2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lock_rounded, + size: 12, + color: colorScheme.error.withValues(alpha: 0.8), + ), + const SizedBox(width: 4), + Text( + 'Need ${(reqPoints - _userPoints).toStringAsFixed(0)} more pts', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.error, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _TicketDividerPainter extends CustomPainter { + final Color color; + _TicketDividerPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + + double maxH = size.height; + double dashHeight = 4.0; + double dashSpace = 4.0; + double currentY = 0.0; + + while (currentY < maxH) { + canvas.drawLine( + Offset(0, currentY), + Offset(0, currentY + dashHeight), + paint, + ); + currentY += dashHeight + dashSpace; + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/services/odoo_service.dart b/lib/services/odoo_service.dart index 63ccad8..4d6fdce 100644 --- a/lib/services/odoo_service.dart +++ b/lib/services/odoo_service.dart @@ -303,4 +303,30 @@ class OdooService { } } } + + /// Fetch active loyalty rewards for a list of program IDs. + Future> getLoyaltyRewards(List programIds) async { + if (programIds.isEmpty) return []; + return await callKw({ + 'model': 'loyalty.reward', + 'method': 'search_read', + 'args': [ + [ + ['program_id', 'in', programIds], + ['program_id.active', '=', true], + ], + ], + 'kwargs': { + 'fields': [ + 'id', + 'program_id', + 'description', + 'required_points', + 'reward_type', + 'reward_product_id', + 'reward_product_qty', + ] + } + }) as List; + } } diff --git a/lib/utils/safe_cast.dart b/lib/utils/safe_cast.dart index 513e19d..7e2c11c 100644 --- a/lib/utils/safe_cast.dart +++ b/lib/utils/safe_cast.dart @@ -7,3 +7,14 @@ String? safeString(dynamic value) { } return null; } + +/// Safely converts a dynamic value from Odoo JSON-RPC to a double. +double safeDouble(dynamic value) { + if (value is num) { + return value.toDouble(); + } + if (value is String) { + return double.tryParse(value) ?? 0.0; + } + return 0.0; +} diff --git a/lib/widgets/promo_card_widget.dart b/lib/widgets/promo_card_widget.dart index 09a6dd9..8d26dd1 100644 --- a/lib/widgets/promo_card_widget.dart +++ b/lib/widgets/promo_card_widget.dart @@ -18,13 +18,6 @@ class PromoCardRow extends StatelessWidget { 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(