feat: Phase 3 — 26 medium-priority gaps implemented
P3-A/K Profile: Eventify ID glassmorphic badge (tap-to-copy), DiceBear
Notionists avatar via TierAvatarRing, district picker (14 pills)
with 183-day cooldown lock, multipart photo upload to server
P3-B Home: Top Events converted to PageView scroll-snap
(viewportFraction 0.9 + PageScrollPhysics)
P3-C Event detail: contributor widget (tier ring + name + navigation),
related events horizontal row; added getEventsByCategory() to
EventsService; added contributorId/Name/Tier fields to EventModel
P3-D Kerala pincodes: 463-entry JSON (all 14 districts), registered as
asset, async-loaded in SearchScreen replacing hardcoded 32 cities
P3-E Checkout: promo code field + Apply/Remove button in Step 2,
discountAmount subtracted from total, applyPromo()/resetPromo()
methods in CheckoutProvider
P3-F/G Gamification: reward cycle countdown + EP→RP progress bar (blue→
amber) in contribute + profile screens; TierAvatarRing in podium
and all leaderboard rows; GlassCard current-user stats card at
top of leaderboard tab
P3-H New ContributorProfileScreen: tier ring, stats, submission grid
with status chips; getDashboardForUser() in GamificationService;
wired from leaderboard row taps
P3-I Achievements: 11 default badges (up from 6), 6 new icon map
entries; progress % labels already confirmed present
P3-J Reviews: CustomPainter circular arc rating ring (amber, 84px)
replaces large rating number in ReviewSummary
P3-L Share rank card: RepaintBoundary → PNG capture → Share.shareXFiles;
share button wired in profile header and leaderboard tab
P3-M SafeArea audit: home bottom nav, contribute/achievements scroll
padding, profile CustomScrollView top inset
New files: tier_avatar_ring.dart, glass_card.dart,
eventify_bottom_sheet.dart, contributor_profile_screen.dart,
share_rank_card.dart, assets/data/kerala_pincodes.json
New dep: path_provider ^2.1.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,11 @@ import 'package:share_plus/share_plus.dart';
|
||||
import '../core/app_decoration.dart';
|
||||
import '../features/gamification/models/gamification_models.dart';
|
||||
import '../features/gamification/providers/gamification_provider.dart';
|
||||
import '../widgets/glass_card.dart';
|
||||
import '../widgets/landscape_section_header.dart';
|
||||
import '../widgets/tier_avatar_ring.dart';
|
||||
import '../features/share/share_rank_card.dart';
|
||||
import 'contributor_profile_screen.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tier colour map
|
||||
@@ -29,12 +33,19 @@ const _tierColors = <ContributorTier, Color>{
|
||||
|
||||
// Icon map for achievement badges
|
||||
const _badgeIcons = <String, IconData>{
|
||||
'edit': Icons.edit_outlined,
|
||||
'star': Icons.star_outline,
|
||||
'emoji_events': Icons.emoji_events_outlined,
|
||||
'leaderboard': Icons.leaderboard_outlined,
|
||||
'photo_library': Icons.photo_library_outlined,
|
||||
'verified': Icons.verified_outlined,
|
||||
'edit': Icons.edit_outlined,
|
||||
'star': Icons.star_outline,
|
||||
'emoji_events': Icons.emoji_events_outlined,
|
||||
'leaderboard': Icons.leaderboard_outlined,
|
||||
'photo_library': Icons.photo_library_outlined,
|
||||
'verified': Icons.verified_outlined,
|
||||
// ACH-002: icons for expanded badge set (badges 02, 06–11)
|
||||
'trending_up': Icons.trending_up,
|
||||
'rocket_launch': Icons.rocket_launch_outlined,
|
||||
'event_hunter': Icons.search_outlined,
|
||||
'location_on': Icons.location_on_outlined,
|
||||
'diamond': Icons.diamond_outlined,
|
||||
'workspace_premium': Icons.workspace_premium_outlined,
|
||||
};
|
||||
|
||||
// District list for the contribution form
|
||||
@@ -254,7 +265,56 @@ class _ContributeScreenState extends State<ContributeScreen>
|
||||
children: [
|
||||
_epStatCard('Lifetime EP', '${profile?.lifetimeEp ?? 0}', Icons.star, const Color(0xFFF59E0B)),
|
||||
const SizedBox(width: 8),
|
||||
_epStatCard('Liquid EP', '${profile?.currentEp ?? 0}', Icons.bolt, const Color(0xFF3B82F6)),
|
||||
// GAM-003 + GAM-004: Liquid EP card with cycle countdown and progress
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF3B82F6).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF3B82F6).withOpacity(0.3)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.bolt, color: Color(0xFF3B82F6), size: 20),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${profile?.currentEp ?? 0}',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const Text('Liquid EP', style: TextStyle(color: Colors.white60, fontSize: 10), textAlign: TextAlign.center),
|
||||
if (provider.currentUserStats?.rewardCycleDays != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Converts in ${provider.currentUserStats!.rewardCycleDays}d',
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Builder(
|
||||
builder: (_) {
|
||||
final days = provider.currentUserStats?.rewardCycleDays ?? 30;
|
||||
final elapsed = (30 - days).clamp(0, 30);
|
||||
final ratio = elapsed / 30;
|
||||
return LinearProgressIndicator(
|
||||
value: ratio,
|
||||
minHeight: 4,
|
||||
backgroundColor: Colors.white12,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
ratio > 0.7 ? const Color(0xFFFBBF24) : const Color(0xFF3B82F6),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_epStatCard('Reward Pts', '${profile?.currentRp ?? 0}', Icons.redeem, const Color(0xFF10B981)),
|
||||
],
|
||||
@@ -1169,20 +1229,34 @@ class _ContributeScreenState extends State<ContributeScreen>
|
||||
|
||||
// Badge icon colors
|
||||
final iconColors = <String, Color>{
|
||||
'edit': const Color(0xFF3B82F6),
|
||||
'star': const Color(0xFFF59E0B),
|
||||
'emoji_events': const Color(0xFFF97316),
|
||||
'leaderboard': const Color(0xFF8B5CF6),
|
||||
'photo_library': const Color(0xFF6B7280),
|
||||
'verified': const Color(0xFF10B981),
|
||||
'edit': const Color(0xFF3B82F6),
|
||||
'star': const Color(0xFFF59E0B),
|
||||
'emoji_events': const Color(0xFFF97316),
|
||||
'leaderboard': const Color(0xFF8B5CF6),
|
||||
'photo_library': const Color(0xFF6B7280),
|
||||
'verified': const Color(0xFF10B981),
|
||||
// ACH-002: colors for expanded badge set
|
||||
'trending_up': const Color(0xFF0EA5E9),
|
||||
'rocket_launch': const Color(0xFFEC4899),
|
||||
'event_hunter': const Color(0xFF64748B),
|
||||
'location_on': const Color(0xFF22C55E),
|
||||
'diamond': const Color(0xFF06B6D4),
|
||||
'workspace_premium': const Color(0xFFE879F9),
|
||||
};
|
||||
final bgColors = <String, Color>{
|
||||
'edit': const Color(0xFFDBEAFE),
|
||||
'star': const Color(0xFFFEF3C7),
|
||||
'emoji_events': const Color(0xFFFED7AA),
|
||||
'leaderboard': const Color(0xFFEDE9FE),
|
||||
'photo_library': const Color(0xFFF3F4F6),
|
||||
'verified': const Color(0xFFD1FAE5),
|
||||
'edit': const Color(0xFFDBEAFE),
|
||||
'star': const Color(0xFFFEF3C7),
|
||||
'emoji_events': const Color(0xFFFED7AA),
|
||||
'leaderboard': const Color(0xFFEDE9FE),
|
||||
'photo_library': const Color(0xFFF3F4F6),
|
||||
'verified': const Color(0xFFD1FAE5),
|
||||
// ACH-002: backgrounds for expanded badge set
|
||||
'trending_up': const Color(0xFFE0F2FE),
|
||||
'rocket_launch': const Color(0xFFFCE7F3),
|
||||
'event_hunter': const Color(0xFFF1F5F9),
|
||||
'location_on': const Color(0xFFDCFCE7),
|
||||
'diamond': const Color(0xFFCFFAFE),
|
||||
'workspace_premium': const Color(0xFFFAE8FF),
|
||||
};
|
||||
|
||||
final iconColor = isUnlocked ? (iconColors[badge.iconName] ?? const Color(0xFF6B7280)) : const Color(0xFF9CA3AF);
|
||||
@@ -1520,9 +1594,10 @@ class _ContributeScreenState extends State<ContributeScreen>
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
Widget _buildContributeTab(BuildContext context, GamificationProvider provider) {
|
||||
final theme = Theme.of(context);
|
||||
final bottomInset = MediaQuery.of(context).padding.bottom;
|
||||
return SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 32),
|
||||
padding: EdgeInsets.fromLTRB(16, 20, 16, 32 + bottomInset),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
@@ -2021,6 +2096,26 @@ class _ContributeScreenState extends State<ContributeScreen>
|
||||
// ── Time period toggle (top-right) + district scroll ──────────────────
|
||||
_buildLeaderboardFilters(provider),
|
||||
|
||||
// LDR-003: Current user stats card at top of leaderboard
|
||||
if (provider.currentUserStats != null)
|
||||
Builder(builder: (context) {
|
||||
final stats = provider.currentUserStats!;
|
||||
return GlassCard(
|
||||
margin: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildStatChip('Rank', '#${stats.rank}', Icons.leaderboard),
|
||||
Container(width: 1, height: 32, color: Colors.white12),
|
||||
_buildStatChip('EP', '${stats.points}', Icons.bolt),
|
||||
Container(width: 1, height: 32, color: Colors.white12),
|
||||
_buildStatChip('Cycle', '${stats.rewardCycleDays}d', Icons.timelapse),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: const Color(0xFFFAFBFC),
|
||||
@@ -2183,29 +2278,16 @@ class _ContributeScreenState extends State<ContributeScreen>
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// Avatar with rank badge overlaid
|
||||
// GAM-006: Avatar with tier ring + rank badge overlaid
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// Avatar circle
|
||||
Container(
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFFE0F2FE),
|
||||
border: Border.all(color: pillarColors[i], width: 2.5),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
e.username.isNotEmpty ? e.username[0].toUpperCase() : '?',
|
||||
style: TextStyle(
|
||||
fontSize: i == 1 ? 24 : 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: pillarColors[i],
|
||||
),
|
||||
),
|
||||
),
|
||||
// TierAvatarRing — tier-coloured glow ring
|
||||
TierAvatarRing(
|
||||
username: e.username,
|
||||
tier: tierLabel(e.tier),
|
||||
size: avatarSize,
|
||||
imageUrl: e.avatarUrl,
|
||||
),
|
||||
// Rank badge — bottom-right corner of avatar
|
||||
Positioned(
|
||||
@@ -2267,7 +2349,17 @@ class _ContributeScreenState extends State<ContributeScreen>
|
||||
final tierColor = _tierColors[entry.tier]!;
|
||||
final isMe = entry.isCurrentUser;
|
||||
|
||||
return Container(
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ContributorProfileScreen(
|
||||
contributorId: entry.username,
|
||||
contributorName: entry.username,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isMe ? const Color(0xFFEFF6FF) : Colors.white,
|
||||
border: const Border(bottom: BorderSide(color: Color(0xFFE5E7EB), width: 0.8)),
|
||||
@@ -2287,21 +2379,12 @@ class _ContributeScreenState extends State<ContributeScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Avatar circle
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFFE0F2FE),
|
||||
border: Border.all(color: tierColor.withOpacity(0.5), width: 1.5),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
entry.username.isNotEmpty ? entry.username[0].toUpperCase() : '?',
|
||||
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14, color: tierColor),
|
||||
),
|
||||
),
|
||||
// GAM-006: TierAvatarRing replaces plain avatar circle
|
||||
TierAvatarRing(
|
||||
username: entry.username,
|
||||
tier: tierLabel(entry.tier),
|
||||
size: 36,
|
||||
imageUrl: entry.avatarUrl,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Name
|
||||
@@ -2360,6 +2443,7 @@ class _ContributeScreenState extends State<ContributeScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2386,10 +2470,19 @@ class _ContributeScreenState extends State<ContributeScreen>
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Share.share(
|
||||
'I\'m ranked #${me.rank} on @EventifyPlus with ${me.lifetimeEp} EP! 🏆 '
|
||||
'Discover & contribute to events near you at eventifyplus.com',
|
||||
subject: 'My Eventify.Plus Leaderboard Rank',
|
||||
final gam = context.read<GamificationProvider>();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: ShareRankCard(
|
||||
username: me.username,
|
||||
tier: tierLabel(me.tier),
|
||||
rank: me.rank,
|
||||
ep: me.lifetimeEp,
|
||||
rewardPoints: gam.profile?.currentRp ?? 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
@@ -2410,6 +2503,19 @@ class _ContributeScreenState extends State<ContributeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// LDR-003: Stat chip helper for current-user leaderboard card
|
||||
Widget _buildStatChip(String label, String value, IconData icon) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: const Color(0xFF94A3B8)),
|
||||
const SizedBox(height: 2),
|
||||
Text(value, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
Text(label, style: const TextStyle(fontSize: 10, color: Color(0xFF64748B))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TAB 2 — ACHIEVEMENTS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -2421,9 +2527,10 @@ class _ContributeScreenState extends State<ContributeScreen>
|
||||
}
|
||||
|
||||
final badges = provider.achievements;
|
||||
final bottomInset = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 32 + bottomInset),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
Reference in New Issue
Block a user