design: redesign subscription list as distinct premium cards and add client-side filtering to avoid overlap

This commit is contained in:
Suherdy Yacob 2026-06-14 11:02:10 +07:00
parent e73fc63e87
commit eae35b20cd
2 changed files with 132 additions and 102 deletions

View File

@ -37,10 +37,28 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
OdooService().getCmsContent(),
]);
final cards = results[0] as List<dynamic>;
final subs = results[1] as List<dynamic>;
final rawCards = results[0] as List<dynamic>;
final rawSubs = results[1] as List<dynamic>;
final cms = results[2] as Map<String, dynamic>;
final List<dynamic> cards = [];
final List<dynamic> subs = [...rawSubs];
for (var card in rawCards) {
final progName = (card['program_id']?[1] as String? ?? '').toLowerCase();
final isSub = progName.contains('subscription') ||
card['subscription_start_date'] != null ||
card['subscription_end_date'] != null;
if (isSub) {
final code = card['code'];
if (!subs.any((s) => s['code'] == code)) {
subs.add(card);
}
} else {
cards.add(card);
}
}
if (mounted) {
setState(() {
_loyaltyCards = cards;

View File

@ -1,8 +1,9 @@
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.
/// "My Subscriptions" list displayed on the home tab below the loyalty card.
/// Renders each subscription as a beautiful standalone card visually distinct
/// from the points-based loyalty card.
class SubscriptionListWidget extends StatelessWidget {
final List<dynamic> subscriptions;
@ -16,44 +17,32 @@ class SubscriptionListWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 10),
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: Text(
'MY SUBSCRIPTIONS',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: AppTheme.onSurfaceVariant,
letterSpacing: 1.2,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
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);
}),
),
),
...subscriptions.map((sub) => _SubscriptionCard(sub: sub)),
],
);
}
}
class _SubscriptionTile extends StatelessWidget {
class _SubscriptionCard extends StatelessWidget {
final dynamic sub;
final bool isLast;
const _SubscriptionTile({required this.sub, required this.isLast});
const _SubscriptionCard({required this.sub});
/// 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
if (endRaw == null || endRaw == false) return true; // no end date = active
try {
final endDate = DateTime.parse(endRaw.toString());
return endDate.isAfter(DateTime.now());
@ -94,94 +83,117 @@ class _SubscriptionTile extends StatelessWidget {
final active = _isActive();
return Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: isLast
? null
: const Border(
bottom: BorderSide(
color: AppTheme.surfaceContainer,
width: 1,
),
),
color: AppTheme.surfaceContainerLowest,
border: Border.all(
color: active ? AppTheme.secondaryContainer : AppTheme.surfaceContainerHighest,
width: 1.5,
),
borderRadius: BorderRadius.zero,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
Icons.card_membership_rounded,
size: 18,
color: active ? AppTheme.secondary : AppTheme.outlineVariant,
),
const SizedBox(width: 8),
Text(
code,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.outlineVariant,
fontFamily: 'monospace',
fontSize: 11,
'SUBSCRIPTION CARD',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: active ? AppTheme.secondary : AppTheme.outlineVariant,
fontWeight: FontWeight.bold,
fontSize: 10,
letterSpacing: 1.0,
),
),
],
],
),
),
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),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: active
? const Color(0xFF1B5E20).withValues(alpha: 0.10)
: const Color(0xFFB02500).withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(4),
),
child: Text(
active ? 'ACTIVE' : 'EXPIRED',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
letterSpacing: 0.8,
color: active
? const Color(0xFF2E7D32)
: const Color(0xFFB02500),
),
),
),
],
),
const SizedBox(height: 14),
Text(
programName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: AppTheme.onSurface,
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Card Number',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.onSurfaceVariant,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
code.isNotEmpty ? code : 'N/A',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.bold,
color: AppTheme.onSurface,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'Validity Period',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.onSurfaceVariant,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
'$startDate - $endDate',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.onSurface,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
],
),