feat: Phase 2 — 11 high-priority gaps implemented across home, auth, gamification, profile, and event detail
Phase 2 gaps completed: - HOME-001: Hero slider pause-on-touch (GestureDetector wraps PageView) - HOME-003: Calendar bottom sheet with TableCalendar (replaces custom dialog) - AUTH-004: District dropdown on signup (14 Kerala districts) - EVT-003: Mobile sticky "Book Now" bar + desktop CTA wired to CheckoutScreen - ACH-001: Real achievements from dashboard API with fallback defaults - GAM-002: 3-card EP row (Lifetime EP / Liquid EP / Reward Points) - GAM-005: Horizontal tier roadmap Bronze→Silver→Gold→Platinum→Diamond - CTR-001: Submission status chips (PENDING/APPROVED/REJECTED) - CTR-002: +EP badge on approved submissions - PROF-003: Gamification cards on profile screen with Consumer<GamificationProvider> - UX-001: Shimmer skeleton loaders (shimmer ^3.0.0) replacing CircularProgressIndicator Already complete (verified, no changes needed): - HOME-002: Category shelves already built in _buildTypeSection() - LDR-002: Podium visualization already built (_buildPodium / _buildDesktopPodium) - BOOK-004: UPI handled natively by Razorpay SDK Deferred: LOC-001/002 (needs Django haversine endpoint) Skipped: AUTH-002 (OTP needs SMS provider decision) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -243,10 +243,12 @@ class SubmissionModel {
|
||||
class DashboardResponse {
|
||||
final UserGamificationProfile profile;
|
||||
final List<SubmissionModel> submissions;
|
||||
final List<AchievementBadge> achievements;
|
||||
|
||||
const DashboardResponse({
|
||||
required this.profile,
|
||||
this.submissions = const [],
|
||||
this.achievements = const [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,4 +332,15 @@ class AchievementBadge {
|
||||
required this.isUnlocked,
|
||||
required this.progress,
|
||||
});
|
||||
|
||||
factory AchievementBadge.fromJson(Map<String, dynamic> json) {
|
||||
return AchievementBadge(
|
||||
id: (json['id'] ?? json['badge_id'] ?? '').toString(),
|
||||
title: (json['title'] ?? json['name'] ?? '').toString(),
|
||||
description: (json['description'] ?? '').toString(),
|
||||
iconName: (json['icon_name'] ?? json['icon'] ?? 'star').toString(),
|
||||
isUnlocked: json['is_unlocked'] == true || json['unlocked'] == true,
|
||||
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,19 @@ class GamificationProvider extends ChangeNotifier {
|
||||
bool isLoading = false;
|
||||
String? error;
|
||||
|
||||
// TTL guard — prevents redundant API calls from multiple screens
|
||||
DateTime? _lastLoadTime;
|
||||
static const _loadTtl = Duration(minutes: 2);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load everything at once (called when ContributeScreen is mounted)
|
||||
// Load everything at once (called when ContributeScreen or ProfileScreen mounts)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> loadAll() async {
|
||||
Future<void> loadAll({bool force = false}) async {
|
||||
// Skip if recently loaded (within 2 minutes) unless forced
|
||||
if (!force && _lastLoadTime != null && DateTime.now().difference(_lastLoadTime!) < _loadTtl) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
@@ -50,7 +59,13 @@ class GamificationProvider extends ChangeNotifier {
|
||||
totalParticipants = lbResponse.totalParticipants;
|
||||
|
||||
shopItems = results[2] as List<ShopItem>;
|
||||
achievements = results[3] as List<AchievementBadge>;
|
||||
|
||||
// Prefer achievements from dashboard API; fall back to getAchievements()
|
||||
final dashAchievements = dashboard.achievements;
|
||||
final fetchedAchievements = results[3] as List<AchievementBadge>;
|
||||
achievements = dashAchievements.isNotEmpty ? dashAchievements : fetchedAchievements;
|
||||
|
||||
_lastLoadTime = DateTime.now();
|
||||
} catch (e) {
|
||||
error = userFriendlyError(e);
|
||||
} finally {
|
||||
|
||||
@@ -28,14 +28,20 @@ class GamificationService {
|
||||
|
||||
final profileJson = res['profile'] as Map<String, dynamic>? ?? {};
|
||||
final rawSubs = res['submissions'] as List? ?? [];
|
||||
final rawAchievements = res['achievements'] as List? ?? [];
|
||||
|
||||
final submissions = rawSubs
|
||||
.map((s) => SubmissionModel.fromJson(Map<String, dynamic>.from(s as Map)))
|
||||
.toList();
|
||||
|
||||
final achievements = rawAchievements
|
||||
.map((a) => AchievementBadge.fromJson(Map<String, dynamic>.from(a as Map)))
|
||||
.toList();
|
||||
|
||||
return DashboardResponse(
|
||||
profile: UserGamificationProfile.fromJson(profileJson),
|
||||
submissions: submissions,
|
||||
achievements: achievements,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,42 +138,25 @@ class GamificationService {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Achievements
|
||||
// TODO: wire to achievements API when available on Node.js server
|
||||
// Achievements — sourced from dashboard API `achievements` array.
|
||||
// Falls back to default badges if API doesn't return achievements yet.
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<List<AchievementBadge>> getAchievements() async {
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
return const [
|
||||
AchievementBadge(
|
||||
id: 'badge-01', title: 'First Submission',
|
||||
description: 'Submitted your first event.',
|
||||
iconName: 'edit', isUnlocked: true, progress: 1.0,
|
||||
),
|
||||
AchievementBadge(
|
||||
id: 'badge-02', title: 'Silver Streak',
|
||||
description: 'Reached Silver tier.',
|
||||
iconName: 'star', isUnlocked: true, progress: 1.0,
|
||||
),
|
||||
AchievementBadge(
|
||||
id: 'badge-03', title: 'Gold Rush',
|
||||
description: 'Reach Gold tier (500 EP).',
|
||||
iconName: 'emoji_events', isUnlocked: false, progress: 0.64,
|
||||
),
|
||||
AchievementBadge(
|
||||
id: 'badge-04', title: 'Top 10',
|
||||
description: 'Appear in the district leaderboard top 10.',
|
||||
iconName: 'leaderboard', isUnlocked: false, progress: 0.5,
|
||||
),
|
||||
AchievementBadge(
|
||||
id: 'badge-05', title: 'Image Pro',
|
||||
description: 'Submit 10 events with 3+ images.',
|
||||
iconName: 'photo_library', isUnlocked: false, progress: 0.3,
|
||||
),
|
||||
AchievementBadge(
|
||||
id: 'badge-06', title: 'Pioneer',
|
||||
description: 'One of the first 100 contributors.',
|
||||
iconName: 'verified', isUnlocked: true, progress: 1.0,
|
||||
),
|
||||
];
|
||||
try {
|
||||
final dashboard = await getDashboard();
|
||||
if (dashboard.achievements.isNotEmpty) return dashboard.achievements;
|
||||
} catch (_) {
|
||||
// Fall through to defaults
|
||||
}
|
||||
return _defaultBadges;
|
||||
}
|
||||
|
||||
static const _defaultBadges = [
|
||||
AchievementBadge(id: 'badge-01', title: 'First Submission', description: 'Submitted your first event.', iconName: 'edit', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-02', title: 'Silver Streak', description: 'Reached Silver tier.', iconName: 'star', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-03', title: 'Gold Rush', description: 'Reach Gold tier (500 EP).', iconName: 'emoji_events', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-04', title: 'Top 10', description: 'Appear in the district leaderboard top 10.', iconName: 'leaderboard', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-05', title: 'Image Pro', description: 'Submit 10 events with 3+ images.', iconName: 'photo_library', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-06', title: 'Pioneer', description: 'One of the first 100 contributors.', iconName: 'verified', isUnlocked: false, progress: 0.0),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user