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
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
// lib/features/booking/providers/checkout_provider.dart
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../../core/api/api_endpoints.dart';
|
||||
import '../../../core/utils/error_utils.dart';
|
||||
import '../models/booking_models.dart';
|
||||
import '../services/booking_service.dart';
|
||||
@@ -24,8 +28,11 @@ class CheckoutProvider extends ChangeNotifier {
|
||||
// Shipping
|
||||
ShippingDetails? shippingDetails;
|
||||
|
||||
// Coupon
|
||||
// Coupon / promo
|
||||
String? couponCode;
|
||||
double discountAmount = 0.0;
|
||||
String? promoMessage;
|
||||
bool promoApplied = false;
|
||||
|
||||
// Status
|
||||
bool loading = false;
|
||||
@@ -40,6 +47,9 @@ class CheckoutProvider extends ChangeNotifier {
|
||||
cart = [];
|
||||
shippingDetails = null;
|
||||
couponCode = null;
|
||||
discountAmount = 0.0;
|
||||
promoMessage = null;
|
||||
promoApplied = false;
|
||||
paymentId = null;
|
||||
error = null;
|
||||
loading = true;
|
||||
@@ -65,7 +75,7 @@ class CheckoutProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
double get subtotal => cart.fold(0, (sum, item) => sum + item.subtotal);
|
||||
double get total => subtotal; // expand with discount/tax later
|
||||
double get total => subtotal - discountAmount;
|
||||
|
||||
bool get hasItems => cart.isNotEmpty;
|
||||
|
||||
@@ -95,6 +105,62 @@ class CheckoutProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Apply a promo code against the backend.
|
||||
Future<bool> applyPromo(String code) async {
|
||||
if (code.trim().isEmpty) return false;
|
||||
loading = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('access_token') ?? '';
|
||||
final response = await http.post(
|
||||
Uri.parse('${ApiEndpoints.baseUrl}/bookings/apply-promo/'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode({'code': code.trim(), 'event_id': eventId}),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['valid'] == true) {
|
||||
discountAmount = (data['discount_amount'] as num?)?.toDouble() ?? 0.0;
|
||||
couponCode = code.trim();
|
||||
promoMessage = data['message'] as String? ?? 'Promo applied!';
|
||||
promoApplied = true;
|
||||
notifyListeners();
|
||||
return true;
|
||||
} else {
|
||||
promoMessage = data['message'] as String? ?? 'Invalid promo code';
|
||||
promoApplied = false;
|
||||
discountAmount = 0.0;
|
||||
couponCode = null;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
promoMessage = 'Could not apply promo code';
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
promoMessage = 'Could not apply promo code';
|
||||
return false;
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove applied promo code.
|
||||
void resetPromo() {
|
||||
discountAmount = 0.0;
|
||||
couponCode = null;
|
||||
promoMessage = null;
|
||||
promoApplied = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Process checkout on backend.
|
||||
Future<Map<String, dynamic>> processCheckout() async {
|
||||
loading = true;
|
||||
@@ -139,6 +205,9 @@ class CheckoutProvider extends ChangeNotifier {
|
||||
cart = [];
|
||||
shippingDetails = null;
|
||||
couponCode = null;
|
||||
discountAmount = 0.0;
|
||||
promoMessage = null;
|
||||
promoApplied = false;
|
||||
paymentId = null;
|
||||
error = null;
|
||||
loading = false;
|
||||
|
||||
@@ -72,6 +72,11 @@ class EventModel {
|
||||
final double? averageRating;
|
||||
final int? reviewCount;
|
||||
|
||||
// Contributor fields (EVT-001)
|
||||
final String? contributorId;
|
||||
final String? contributorName;
|
||||
final String? contributorTier;
|
||||
|
||||
EventModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@@ -97,6 +102,9 @@ class EventModel {
|
||||
this.importantInfo = const [],
|
||||
this.averageRating,
|
||||
this.reviewCount,
|
||||
this.contributorId,
|
||||
this.contributorName,
|
||||
this.contributorTier,
|
||||
});
|
||||
|
||||
/// Safely parse a double from backend (may arrive as String or num)
|
||||
@@ -156,6 +164,9 @@ class EventModel {
|
||||
importantInfo: _parseImportantInfo(j['important_info']),
|
||||
averageRating: (j['average_rating'] as num?)?.toDouble(),
|
||||
reviewCount: (j['review_count'] as num?)?.toInt(),
|
||||
contributorId: j['contributor_id']?.toString(),
|
||||
contributorName: j['contributor_name'] as String?,
|
||||
contributorTier: j['contributor_tier'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,28 @@ class EventsService {
|
||||
return EventModel.fromJson(Map<String, dynamic>.from(res));
|
||||
}
|
||||
|
||||
/// Related events by event_type_id (EVT-002).
|
||||
/// Fetches events with the same category, silently returns [] on failure.
|
||||
Future<List<EventModel>> getEventsByCategory(int eventTypeId, {int limit = 5}) async {
|
||||
try {
|
||||
final res = await _api.post(
|
||||
ApiEndpoints.eventsByCategory,
|
||||
body: {'event_type_id': eventTypeId, 'page_size': limit, 'page': 1},
|
||||
requiresAuth: false,
|
||||
);
|
||||
final results = res['results'] ?? res['events'] ?? res['data'] ?? [];
|
||||
if (results is List) {
|
||||
return results
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) => EventModel.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
} catch (_) {
|
||||
// silently fail — related events are non-critical
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Events by month and year for calendar (POST to /events/events-by-month-year/)
|
||||
Future<Map<String, dynamic>> getEventsByMonthYear(String month, int year) async {
|
||||
final res = await _api.post(ApiEndpoints.eventsByMonth, body: {'month': month, 'year': year}, requiresAuth: false);
|
||||
|
||||
@@ -45,6 +45,33 @@ class GamificationService {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public contributor profile (any user by userId / email)
|
||||
// GET /v1/gamification/dashboard?user_id={userId}
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<DashboardResponse> getDashboardForUser(String userId) async {
|
||||
final url = '${ApiEndpoints.gamificationDashboard}?user_id=$userId';
|
||||
final res = await _api.post(url, requiresAuth: false);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience — returns just the profile (backward-compatible with provider).
|
||||
Future<UserGamificationProfile> getProfile() async {
|
||||
final dashboard = await getDashboard();
|
||||
@@ -152,11 +179,16 @@ class GamificationService {
|
||||
}
|
||||
|
||||
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),
|
||||
AchievementBadge(id: 'badge-01', title: 'First Step', description: 'Submit your first event.', iconName: 'edit', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-02', title: 'Silver Streak', description: 'Submit 5 events.', iconName: 'trending_up', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-03', title: 'Gold Rush', description: 'Submit 15 events.', iconName: 'emoji_events', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-04', title: 'Top 10', description: 'Reach top 10 on leaderboard.', iconName: 'leaderboard', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-05', title: 'Image Pro', description: 'Upload 10 event images.', iconName: 'photo_library', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-06', title: 'Pioneer', description: 'One of the first contributors.', iconName: 'rocket_launch', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-07', title: 'Community Star', description: 'Get 10 events approved.', iconName: 'verified', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-08', title: 'Event Hunter', description: 'Submit 25 events.', iconName: 'event_hunter', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-09', title: 'District Champion', description: 'Rank #1 in your district.', iconName: 'location_on', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-10', title: 'Platinum Achiever', description: 'Earn 1500 EP.', iconName: 'diamond', isUnlocked: false, progress: 0.0),
|
||||
AchievementBadge(id: 'badge-11', title: 'Diamond Legend', description: 'Earn 5000 EP.', iconName: 'workspace_premium', isUnlocked: false, progress: 0.0),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,97 @@
|
||||
// lib/features/reviews/widgets/review_summary.dart
|
||||
import 'dart:math' show pi;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/review_models.dart';
|
||||
import 'star_display.dart';
|
||||
|
||||
class _RatingRingPainter extends CustomPainter {
|
||||
final double rating;
|
||||
|
||||
const _RatingRingPainter({required this.rating});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = size.width / 2 - 6;
|
||||
|
||||
// Background track
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
-pi / 2,
|
||||
2 * pi,
|
||||
false,
|
||||
Paint()
|
||||
..color = Colors.white12
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 7
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
|
||||
// Filled arc
|
||||
if (rating > 0) {
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
-pi / 2,
|
||||
(rating.clamp(0.0, 5.0) / 5.0) * 2 * pi,
|
||||
false,
|
||||
Paint()
|
||||
..color = const Color(0xFFFBBF24)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 7
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RatingRingPainter old) => old.rating != rating;
|
||||
}
|
||||
|
||||
class _RatingRingWidget extends StatelessWidget {
|
||||
final double rating;
|
||||
final int reviewCount;
|
||||
|
||||
const _RatingRingWidget({required this.rating, required this.reviewCount});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 84,
|
||||
height: 84,
|
||||
child: CustomPaint(
|
||||
painter: _RatingRingPainter(rating: rating),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
rating.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'/5',
|
||||
style: TextStyle(fontSize: 10, color: Color(0xFF94A3B8)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$reviewCount ${reviewCount == 1 ? 'review' : 'reviews'}',
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReviewSummary extends StatelessWidget {
|
||||
final ReviewStatsModel stats;
|
||||
@@ -24,27 +114,10 @@ class ReviewSummary extends StatelessWidget {
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Left: average rating + stars + count
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
stats.averageRating.toStringAsFixed(1),
|
||||
style: const TextStyle(
|
||||
fontSize: 40,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1E293B),
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
StarDisplay(rating: stats.averageRating, size: 18),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${stats.reviewCount} review${stats.reviewCount == 1 ? '' : 's'}',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B)),
|
||||
),
|
||||
],
|
||||
// Left: circular rating ring
|
||||
_RatingRingWidget(
|
||||
rating: stats.averageRating,
|
||||
reviewCount: stats.reviewCount,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
// Right: distribution bars
|
||||
|
||||
197
lib/features/share/share_rank_card.dart
Normal file
197
lib/features/share/share_rank_card.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../../widgets/tier_avatar_ring.dart';
|
||||
|
||||
class ShareRankCard extends StatefulWidget {
|
||||
final String username;
|
||||
final String tier;
|
||||
final int rank;
|
||||
final int ep;
|
||||
final int rewardPoints;
|
||||
|
||||
const ShareRankCard({
|
||||
super.key,
|
||||
required this.username,
|
||||
required this.tier,
|
||||
required this.rank,
|
||||
required this.ep,
|
||||
this.rewardPoints = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ShareRankCard> createState() => _ShareRankCardState();
|
||||
}
|
||||
|
||||
class _ShareRankCardState extends State<ShareRankCard> {
|
||||
final GlobalKey _boundaryKey = GlobalKey();
|
||||
bool _sharing = false;
|
||||
|
||||
static const _tierGradients = {
|
||||
'Bronze': [Color(0xFF92400E), Color(0xFFD97706)],
|
||||
'Silver': [Color(0xFF475569), Color(0xFF94A3B8)],
|
||||
'Gold': [Color(0xFF92400E), Color(0xFFFBBF24)],
|
||||
'Platinum': [Color(0xFF4C1D95), Color(0xFF8B5CF6)],
|
||||
'Diamond': [Color(0xFF1E3A8A), Color(0xFF60A5FA)],
|
||||
};
|
||||
|
||||
List<Color> get _gradient {
|
||||
return _tierGradients[widget.tier] ?? [const Color(0xFF0F172A), const Color(0xFF1E293B)];
|
||||
}
|
||||
|
||||
Future<void> _share() async {
|
||||
if (_sharing) return;
|
||||
setState(() => _sharing = true);
|
||||
try {
|
||||
final boundary = _boundaryKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
||||
final image = await boundary.toImage(pixelRatio: 3.0);
|
||||
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData == null) return;
|
||||
final bytes = byteData.buffer.asUint8List();
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/eventify_rank_${widget.username}.png');
|
||||
await file.writeAsBytes(bytes);
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
text: 'I\'m ranked #${widget.rank} on Eventify with ${widget.ep} EP! 🏆 #Eventify #Kerala',
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Could not share rank card')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _sharing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RepaintBoundary(
|
||||
key: _boundaryKey,
|
||||
child: Container(
|
||||
width: 320,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF0F172A), Color(0xFF1E293B)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Tier gradient header bar
|
||||
Container(
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(colors: _gradient),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Avatar
|
||||
TierAvatarRing(username: widget.username, tier: widget.tier, size: 80),
|
||||
const SizedBox(height: 12),
|
||||
// Username
|
||||
Text(
|
||||
widget.username,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w800, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Tier badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(colors: _gradient),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
widget.tier.isEmpty ? 'Contributor' : widget.tier,
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Stats row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_stat('Rank', '#${widget.rank}'),
|
||||
Container(width: 1, height: 40, color: Colors.white12),
|
||||
_stat('EP', '${widget.ep}'),
|
||||
Container(width: 1, height: 40, color: Colors.white12),
|
||||
_stat('RP', '${widget.rewardPoints}'),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Branding
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(Icons.bolt, size: 14, color: Color(0xFF3B82F6)),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'EVENTIFY',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFF3B82F6),
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _sharing ? null : _share,
|
||||
icon: _sharing
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Icon(Icons.share, size: 18),
|
||||
label: Text(_sharing ? 'Sharing...' : 'Share Rank Card'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF1D4ED8),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _stat(String label, String value) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class _CheckoutScreenState extends State<CheckoutScreen> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _emailCtrl = TextEditingController();
|
||||
final _phoneCtrl = TextEditingController();
|
||||
final _promoCtrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -77,6 +78,7 @@ class _CheckoutScreenState extends State<CheckoutScreen> {
|
||||
_nameCtrl.dispose();
|
||||
_emailCtrl.dispose();
|
||||
_phoneCtrl.dispose();
|
||||
_promoCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -253,6 +255,84 @@ class _CheckoutScreenState extends State<CheckoutScreen> {
|
||||
_field('Full Name', _nameCtrl, validator: (v) => v!.isEmpty ? 'Required' : null),
|
||||
_field('Email', _emailCtrl, type: TextInputType.emailAddress, validator: (v) => v!.isEmpty ? 'Required' : null),
|
||||
_field('Phone', _phoneCtrl, type: TextInputType.phone, validator: (v) => v!.isEmpty ? 'Required' : null),
|
||||
const SizedBox(height: 8),
|
||||
Consumer<CheckoutProvider>(
|
||||
builder: (context, provider, _) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _promoCtrl,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Promo Code (optional)',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade50,
|
||||
suffixIcon: provider.promoApplied
|
||||
? const Icon(Icons.check_circle, color: Colors.green, size: 20)
|
||||
: null,
|
||||
),
|
||||
enabled: !provider.promoApplied,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: provider.promoApplied
|
||||
? OutlinedButton(
|
||||
onPressed: () {
|
||||
provider.resetPromo();
|
||||
_promoCtrl.clear();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Remove'),
|
||||
)
|
||||
: ElevatedButton(
|
||||
onPressed: provider.loading
|
||||
? null
|
||||
: () async {
|
||||
final ok = await provider.applyPromo(_promoCtrl.text);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(provider.promoMessage ??
|
||||
(ok ? 'Promo applied!' : 'Invalid promo code')),
|
||||
backgroundColor: ok ? Colors.green : Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF0B63D6),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Apply'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (provider.promoApplied && provider.promoMessage != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.local_offer, size: 14, color: Colors.green),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${provider.promoMessage} — saves \u20b9${provider.discountAmount.toStringAsFixed(0)}',
|
||||
style: const TextStyle(fontSize: 12, color: Colors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -293,6 +373,34 @@ class _CheckoutScreenState extends State<CheckoutScreen> {
|
||||
),
|
||||
)),
|
||||
const Divider(height: 32),
|
||||
if (provider.promoApplied) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Subtotal', style: TextStyle(color: Colors.grey.shade700)),
|
||||
Text('Rs ${provider.subtotal.toStringAsFixed(0)}', style: TextStyle(color: Colors.grey.shade700)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.local_offer, size: 14, color: Colors.green),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
provider.couponCode != null ? 'Promo (${provider.couponCode})' : 'Promo discount',
|
||||
style: const TextStyle(color: Colors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text('- Rs ${provider.discountAmount.toStringAsFixed(0)}',
|
||||
style: const TextStyle(color: Colors.green, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
|
||||
@@ -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: [
|
||||
|
||||
271
lib/screens/contributor_profile_screen.dart
Normal file
271
lib/screens/contributor_profile_screen.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
// lib/screens/contributor_profile_screen.dart
|
||||
// CTR-004 — Public contributor profile page.
|
||||
// Shows avatar, tier ring, EP stats, and submission grid for any contributor.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../features/gamification/models/gamification_models.dart';
|
||||
import '../features/gamification/services/gamification_service.dart';
|
||||
import '../widgets/tier_avatar_ring.dart';
|
||||
|
||||
class ContributorProfileScreen extends StatefulWidget {
|
||||
final String contributorId;
|
||||
final String contributorName;
|
||||
|
||||
const ContributorProfileScreen({
|
||||
super.key,
|
||||
required this.contributorId,
|
||||
required this.contributorName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ContributorProfileScreen> createState() => _ContributorProfileScreenState();
|
||||
}
|
||||
|
||||
class _ContributorProfileScreenState extends State<ContributorProfileScreen> {
|
||||
DashboardResponse? _data;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final data = await GamificationService().getDashboardForUser(widget.contributorId);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_data = data;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = 'Could not load profile';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0F172A),
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFF0F172A),
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(
|
||||
widget.contributorName,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
body: _loading
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF3B82F6)),
|
||||
)
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Text(
|
||||
_error!,
|
||||
style: const TextStyle(color: Colors.white54),
|
||||
),
|
||||
)
|
||||
: _buildContent(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
final profile = _data!.profile;
|
||||
final submissions = _data!.submissions;
|
||||
final tierStr = tierLabel(profile.tier);
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
// Avatar with tier ring
|
||||
TierAvatarRing(
|
||||
username: widget.contributorName,
|
||||
tier: tierStr,
|
||||
size: 88,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
widget.contributorName,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E3A8A),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
tierStr,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF93C5FD)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Stats row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_statCard('EP', '${profile.currentEp}'),
|
||||
_statCard('Events', '${submissions.length}'),
|
||||
_statCard(
|
||||
'Approved',
|
||||
'${submissions.where((s) => s.status.toUpperCase() == 'APPROVED').length}',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (submissions.isNotEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
|
||||
sliver: SliverGrid(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, i) => _buildSubmissionTile(submissions[i]),
|
||||
childCount: submissions.length,
|
||||
),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
childAspectRatio: 1.1,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Text(
|
||||
'No submissions yet',
|
||||
style: TextStyle(color: Colors.white38),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statCard(String label, String value) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF94A3B8)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmissionTile(SubmissionModel s) {
|
||||
final Color statusColor;
|
||||
switch (s.status.toUpperCase()) {
|
||||
case 'APPROVED':
|
||||
statusColor = const Color(0xFF22C55E);
|
||||
break;
|
||||
case 'REJECTED':
|
||||
statusColor = const Color(0xFFEF4444);
|
||||
break;
|
||||
default:
|
||||
statusColor = const Color(0xFFFBBF24); // PENDING
|
||||
}
|
||||
|
||||
// SubmissionModel.images is List<String>; use first image if present.
|
||||
final String? firstImage = s.images.isNotEmpty ? s.images.first : null;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E293B),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (firstImage != null && firstImage.isNotEmpty)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox.expand(
|
||||
child: Image.network(
|
||||
firstImage,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(color: const Color(0xFF334155)),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
s.status,
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (s.eventName.isNotEmpty)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Colors.black87, Colors.transparent],
|
||||
),
|
||||
borderRadius: BorderRadius.vertical(bottom: Radius.circular(10)),
|
||||
),
|
||||
child: Text(
|
||||
s.eventName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
bool _loading = true;
|
||||
|
||||
// Hero carousel
|
||||
final PageController _heroPageController = PageController(viewportFraction: 0.88);
|
||||
final PageController _heroPageController = PageController(viewportFraction: 0.9);
|
||||
late final ValueNotifier<int> _heroPageNotifier;
|
||||
Timer? _autoScrollTimer;
|
||||
|
||||
@@ -453,10 +453,11 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
),
|
||||
|
||||
// Floating bottom navigation (always visible)
|
||||
// bottom offset accounts for home indicator on iPhone/Android gesture bar
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||
child: _buildFloatingBottomNav(),
|
||||
),
|
||||
],
|
||||
@@ -1532,11 +1533,14 @@ class _HomeScreenState extends State<HomeScreen> with SingleTickerProviderStateM
|
||||
_selectedDateFilter.isNotEmpty ? 'No events for "$_selectedDateFilter"' : 'No events found',
|
||||
style: const TextStyle(color: Color(0xFF9CA3AF)),
|
||||
))
|
||||
: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
: PageView.builder(
|
||||
controller: PageController(viewportFraction: 0.85),
|
||||
physics: const PageScrollPhysics(),
|
||||
itemCount: _allFilteredByDate.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) => _buildTopEventCard(_allFilteredByDate[index]),
|
||||
itemBuilder: (context, index) => Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: _buildTopEventCard(_allFilteredByDate[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -15,6 +15,8 @@ import '../core/auth/auth_guard.dart';
|
||||
import '../core/utils/error_utils.dart';
|
||||
import '../core/constants.dart';
|
||||
import '../features/reviews/widgets/review_section.dart';
|
||||
import '../widgets/tier_avatar_ring.dart';
|
||||
import 'contributor_profile_screen.dart';
|
||||
import 'checkout_screen.dart';
|
||||
|
||||
class LearnMoreScreen extends StatefulWidget {
|
||||
@@ -59,6 +61,10 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
// Google Map
|
||||
GoogleMapController? _mapController;
|
||||
|
||||
// Related events (EVT-002)
|
||||
List<EventModel> _relatedEvents = [];
|
||||
bool _loadingRelated = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -100,6 +106,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
_event = ev;
|
||||
});
|
||||
_startAutoScroll();
|
||||
_loadRelatedEvents();
|
||||
return; // success
|
||||
} catch (e) {
|
||||
debugPrint('_loadFullDetails attempt ${attempt + 1} failed for event ${widget.eventId}: $e');
|
||||
@@ -120,6 +127,7 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() => _event = ev);
|
||||
_startAutoScroll();
|
||||
_loadRelatedEvents();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = userFriendlyError(e));
|
||||
@@ -128,6 +136,19 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch related events by the same event type (EVT-002).
|
||||
Future<void> _loadRelatedEvents() async {
|
||||
if (_event?.eventTypeId == null) return;
|
||||
if (mounted) setState(() => _loadingRelated = true);
|
||||
try {
|
||||
final events = await _service.getEventsByCategory(_event!.eventTypeId!, limit: 6);
|
||||
final filtered = events.where((e) => e.id != widget.eventId).take(5).toList();
|
||||
if (mounted) setState(() => _relatedEvents = filtered);
|
||||
} finally {
|
||||
if (mounted) setState(() => _loadingRelated = false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Carousel helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -441,8 +462,12 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
if (_event!.importantInfo.isEmpty &&
|
||||
(_event!.importantInformation ?? '').isNotEmpty)
|
||||
_buildImportantInfoFallback(theme),
|
||||
// EVT-001: Contributor widget
|
||||
_buildContributorSection(theme),
|
||||
const SizedBox(height: 24),
|
||||
ReviewSection(eventId: widget.eventId),
|
||||
// EVT-002: Related events horizontal row
|
||||
_buildRelatedEventsSection(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -619,11 +644,15 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
if (_event!.importantInfo.isEmpty &&
|
||||
(_event!.importantInformation ?? '').isNotEmpty)
|
||||
_buildImportantInfoFallback(theme),
|
||||
// EVT-001: Contributor widget
|
||||
_buildContributorSection(theme),
|
||||
const SizedBox(height: 24),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0),
|
||||
child: ReviewSection(eventId: widget.eventId),
|
||||
),
|
||||
// EVT-002: Related events horizontal row
|
||||
_buildRelatedEventsSection(theme),
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
@@ -1335,6 +1364,227 @@ class _LearnMoreScreenState extends State<LearnMoreScreen> {
|
||||
return items;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. CONTRIBUTOR WIDGET (EVT-001)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildContributorSection(ThemeData theme) {
|
||||
final name = _event?.contributorName;
|
||||
if (name == null || name.isEmpty) return const SizedBox.shrink();
|
||||
final tier = _event!.contributorTier ?? '';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.brightness == Brightness.dark
|
||||
? const Color(0xFF1E293B)
|
||||
: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.brightness == Brightness.dark
|
||||
? Colors.white.withOpacity(0.08)
|
||||
: theme.dividerColor,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
TierAvatarRing(
|
||||
username: name,
|
||||
tier: tier,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Contributed by',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
name,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (tier.isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
tier,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_event?.contributorId != null)
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_forward_ios,
|
||||
size: 14, color: theme.hintColor),
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ContributorProfileScreen(
|
||||
contributorId: _event!.contributorId!,
|
||||
contributorName: _event!.contributorName!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. RELATED EVENTS ROW (EVT-002)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _buildRelatedEventsSection(ThemeData theme) {
|
||||
if (_loadingRelated) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Related Events',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_relatedEvents.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 8),
|
||||
child: Text(
|
||||
'Related Events',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _relatedEvents.length,
|
||||
itemBuilder: (context, i) {
|
||||
final e = _relatedEvents[i];
|
||||
final displayName = e.title ?? e.name;
|
||||
final imageUrl = e.thumbImg ?? '';
|
||||
return GestureDetector(
|
||||
onTap: () => Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => LearnMoreScreen(eventId: e.id),
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
width: 140,
|
||||
margin: const EdgeInsets.only(right: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: theme.brightness == Brightness.dark
|
||||
? const Color(0xFF1E293B)
|
||||
: theme.cardColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: theme.shadowColor.withOpacity(0.06),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: imageUrl.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
height: 100,
|
||||
width: 140,
|
||||
fit: BoxFit.cover,
|
||||
errorWidget: (_, __, ___) => Container(
|
||||
height: 100,
|
||||
width: 140,
|
||||
color: theme.dividerColor,
|
||||
child: Icon(Icons.event,
|
||||
size: 32, color: theme.hintColor),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
height: 100,
|
||||
width: 140,
|
||||
color: theme.dividerColor,
|
||||
child: Icon(Icons.event,
|
||||
size: 32, color: theme.hintColor),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
displayName,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImportantInfoFallback(ThemeData theme) {
|
||||
final parsed = _parseHtmlImportantInfo(_event!.importantInformation!);
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import '../core/utils/error_utils.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -13,11 +16,14 @@ import '../features/events/models/event_models.dart';
|
||||
import '../features/gamification/providers/gamification_provider.dart';
|
||||
import '../features/gamification/models/gamification_models.dart';
|
||||
import '../widgets/skeleton_loader.dart';
|
||||
import '../widgets/tier_avatar_ring.dart';
|
||||
import 'learn_more_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import '../core/api/api_endpoints.dart';
|
||||
import '../core/app_decoration.dart';
|
||||
import '../core/constants.dart';
|
||||
import '../widgets/landscape_section_header.dart';
|
||||
import '../features/share/share_rank_card.dart';
|
||||
|
||||
class ProfileScreen extends StatefulWidget {
|
||||
const ProfileScreen({Key? key}) : super(key: key);
|
||||
@@ -31,10 +37,35 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
String _username = '';
|
||||
String _email = 'not provided';
|
||||
String _profileImage = '';
|
||||
String? _eventifyId;
|
||||
String? _userTier;
|
||||
String? _district;
|
||||
DateTime? _districtChangedAt;
|
||||
final ImagePicker _picker = ImagePicker();
|
||||
|
||||
// 14 Kerala districts
|
||||
static const List<String> _districts = [
|
||||
'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha',
|
||||
'Kottayam', 'Idukki', 'Ernakulam', 'Thrissur', 'Palakkad',
|
||||
'Malappuram', 'Kozhikode', 'Wayanad', 'Kannur', 'Kasaragod',
|
||||
];
|
||||
|
||||
final EventsService _eventsService = EventsService();
|
||||
|
||||
// AUTH-005: District change cooldown (183-day lock)
|
||||
bool get _districtLocked {
|
||||
if (_districtChangedAt == null) return false;
|
||||
return DateTime.now().difference(_districtChangedAt!) < const Duration(days: 183);
|
||||
}
|
||||
|
||||
String get _districtNextChange {
|
||||
if (_districtChangedAt == null) return '';
|
||||
final next = _districtChangedAt!.add(const Duration(days: 183));
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
return '${next.day} ${months[next.month - 1]} ${next.year}';
|
||||
}
|
||||
|
||||
List<EventModel> _ongoingEvents = [];
|
||||
List<EventModel> _upcomingEvents = [];
|
||||
List<EventModel> _pastEvents = [];
|
||||
@@ -149,6 +180,21 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
_profileImage =
|
||||
prefs.getString(profileImageKey) ?? prefs.getString('profileImage') ?? '';
|
||||
|
||||
// AUTH-003/PROF-001: Eventify ID
|
||||
_eventifyId = prefs.getString('eventify_id');
|
||||
|
||||
// PROF-004 partial: tier for avatar ring
|
||||
_userTier = prefs.getString('user_tier') ?? prefs.getString('level');
|
||||
|
||||
// PROF-002: District
|
||||
_district = prefs.getString('district');
|
||||
|
||||
// AUTH-005: District change cooldown
|
||||
final districtChangedStr = prefs.getString('district_changed_at');
|
||||
if (districtChangedStr != null) {
|
||||
_districtChangedAt = DateTime.tryParse(districtChangedStr);
|
||||
}
|
||||
|
||||
await _loadEventsForProfile(prefs);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
@@ -266,6 +312,8 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
}
|
||||
final String path = xfile.path;
|
||||
await _saveProfile(_username, _email, path);
|
||||
// PROF-004: Upload to server on mobile
|
||||
await _uploadProfilePhoto(path);
|
||||
} catch (e) {
|
||||
debugPrint('Image pick error: $e');
|
||||
ScaffoldMessenger.of(context)
|
||||
@@ -273,6 +321,77 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
}
|
||||
}
|
||||
|
||||
// PROF-004: Upload profile photo to server
|
||||
Future<void> _uploadProfilePhoto(String filePath) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('access_token') ?? '';
|
||||
if (token.isEmpty) return;
|
||||
final request = http.MultipartRequest(
|
||||
'PATCH',
|
||||
Uri.parse('${ApiEndpoints.baseUrl}/user/update-profile/'),
|
||||
);
|
||||
request.headers['Authorization'] = 'Bearer $token';
|
||||
request.files.add(await http.MultipartFile.fromPath('profile_picture', filePath));
|
||||
final response = await request.send();
|
||||
if (response.statusCode == 200) {
|
||||
final body = await response.stream.bytesToString();
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
if (data['profile_picture'] != null) {
|
||||
final newUrl = data['profile_picture'].toString();
|
||||
final prefs2 = await SharedPreferences.getInstance();
|
||||
final currentEmail = prefs2.getString('current_email') ?? prefs2.getString('email') ?? '';
|
||||
final profileImageKey = currentEmail.isNotEmpty ? 'profileImage_$currentEmail' : 'profileImage';
|
||||
await prefs2.setString(profileImageKey, newUrl);
|
||||
if (mounted) setState(() => _profileImage = newUrl);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Photo upload error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// PROF-002: Update district via API with cooldown check
|
||||
Future<void> _updateDistrict(String district) async {
|
||||
if (_districtLocked) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('District locked until $_districtNextChange')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('access_token') ?? '';
|
||||
final response = await http.patch(
|
||||
Uri.parse('${ApiEndpoints.baseUrl}/user/update-profile/'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({'district': district}),
|
||||
);
|
||||
if (response.statusCode == 200) {
|
||||
final now = DateTime.now();
|
||||
await prefs.setString('district', district);
|
||||
await prefs.setString('district_changed_at', now.toIso8601String());
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_district = district;
|
||||
_districtChangedAt = now;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('District update error: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(userFriendlyError(e))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _enterAssetPathDialog() async {
|
||||
final ctl = TextEditingController(text: _profileImage);
|
||||
final result = await showDialog<String?>(
|
||||
@@ -420,6 +539,87 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: theme.hintColor),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// PROF-002: District picker
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'District',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// AUTH-005: Cooldown lock indicator
|
||||
if (_districtLocked)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.lock_outline,
|
||||
size: 14, color: Colors.amber),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'District locked until $_districtNextChange',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: Colors.amber),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// District pill grid
|
||||
StatefulBuilder(
|
||||
builder: (ctx2, setInner) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _districts.map((d) {
|
||||
final isSelected = _district == d;
|
||||
return GestureDetector(
|
||||
onTap: _districtLocked
|
||||
? null
|
||||
: () async {
|
||||
setInner(() {});
|
||||
await _updateDistrict(d);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? const Color(0xFF3B82F6)
|
||||
: theme.cardColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF3B82F6)
|
||||
: theme.dividerColor,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
d,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isSelected
|
||||
? Colors.white
|
||||
: theme.textTheme.bodyMedium?.color,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -433,54 +633,22 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
});
|
||||
}
|
||||
|
||||
// ───────── Avatar builder (reused, with size param) ─────────
|
||||
// ───────── Avatar builder (AUTH-006 / PROF-004: DiceBear via TierAvatarRing) ─────────
|
||||
|
||||
Widget _buildProfileAvatar({double size = 96}) {
|
||||
final path = _profileImage.trim();
|
||||
// Resolve a network-compatible URL: http URLs pass through directly,
|
||||
// file paths and assets fall back to null so DiceBear is used.
|
||||
String? imageUrl;
|
||||
if (path.startsWith('http')) {
|
||||
return ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: path,
|
||||
memCacheWidth: (size * 2).toInt(),
|
||||
memCacheHeight: (size * 2).toInt(),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) =>
|
||||
Container(width: size, height: size, color: const Color(0xFFE5E7EB)),
|
||||
errorWidget: (_, __, ___) =>
|
||||
Icon(Icons.person, size: size / 2, color: Colors.grey)));
|
||||
imageUrl = path;
|
||||
}
|
||||
if (kIsWeb) {
|
||||
return ClipOval(
|
||||
child: Image.asset(
|
||||
path.isNotEmpty ? path : 'assets/images/profile.jpg',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Icon(Icons.person, size: size / 2, color: Colors.grey)));
|
||||
}
|
||||
if (path.isNotEmpty &&
|
||||
(path.startsWith('/') || path.contains(Platform.pathSeparator))) {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) {
|
||||
return ClipOval(
|
||||
child: Image.file(file,
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Icon(Icons.person, size: size / 2, color: Colors.grey)));
|
||||
}
|
||||
}
|
||||
return ClipOval(
|
||||
child: Image.asset('assets/images/profile.jpg',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) =>
|
||||
Icon(Icons.person, size: size / 2, color: Colors.grey)));
|
||||
return TierAvatarRing(
|
||||
username: _username.isNotEmpty ? _username : _email,
|
||||
tier: _userTier ?? '',
|
||||
size: size,
|
||||
imageUrl: imageUrl,
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Event list tile (updated styling) ─────────
|
||||
@@ -636,7 +804,34 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(width: 40), // balance
|
||||
// Share rank card button
|
||||
Consumer<GamificationProvider>(
|
||||
builder: (context, gam, _) => GestureDetector(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: ShareRankCard(
|
||||
username: _username,
|
||||
tier: gam.currentUserStats?.level ?? _userTier ?? '',
|
||||
rank: gam.currentUserStats?.rank ?? 0,
|
||||
ep: gam.profile?.currentEp ?? 0,
|
||||
rewardPoints: gam.profile?.currentRp ?? 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
child: const Icon(Icons.share_outlined, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Profile',
|
||||
@@ -927,6 +1122,51 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// AUTH-003 / PROF-001: Eventify ID badge
|
||||
if (_eventifyId != null && _eventifyId!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: _eventifyId!));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Eventify ID copied'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1E3A8A).withOpacity(0.3),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF3B82F6).withOpacity(0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.badge_outlined,
|
||||
size: 12, color: Color(0xFF93C5FD)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_eventifyId!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF93C5FD),
|
||||
fontFamily: 'monospace',
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Email
|
||||
@@ -1581,7 +1821,10 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
return Scaffold(
|
||||
backgroundColor: theme.scaffoldBackgroundColor,
|
||||
// CustomScrollView: only visible event cards are built — no full-tree Column renders
|
||||
body: CustomScrollView(
|
||||
// SafeArea(bottom: false) — bottom is handled by home screen's floating nav buffer
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
// Header gradient + Profile card overlap (same visual as before)
|
||||
@@ -1667,6 +1910,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1688,7 +1932,56 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
children: [
|
||||
_gamStatCard('Lifetime EP', '${p.lifetimeEp}', Icons.star, const Color(0xFFF59E0B), theme),
|
||||
const SizedBox(width: 10),
|
||||
_gamStatCard('Liquid EP', '${p.currentEp}', Icons.bolt, const Color(0xFF3B82F6), theme),
|
||||
// GAM-003 + GAM-004: Liquid EP with cycle countdown and progress bar
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF3B82F6).withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF3B82F6).withOpacity(0.2)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.bolt, color: Color(0xFF3B82F6), size: 22),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${p.currentEp}',
|
||||
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16, color: theme.textTheme.bodyLarge?.color),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text('Liquid EP', style: TextStyle(color: theme.hintColor, fontSize: 10), textAlign: TextAlign.center),
|
||||
if (gp.currentUserStats?.rewardCycleDays != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Converts in ${gp.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 = gp.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: 10),
|
||||
_gamStatCard('Reward Pts', '${p.currentRp}', Icons.redeem, const Color(0xFF10B981), theme),
|
||||
],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// lib/screens/search_screen.dart
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../core/utils/error_utils.dart';
|
||||
|
||||
// Location packages
|
||||
@@ -46,50 +48,41 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
'Kottayam',
|
||||
];
|
||||
|
||||
/// Searchable location database – Kerala towns/cities with pincodes.
|
||||
static const List<_LocationItem> _locationDb = [
|
||||
_LocationItem(city: 'Thiruvananthapuram', district: 'Thiruvananthapuram', pincode: '695001'),
|
||||
_LocationItem(city: 'Kazhakoottam', district: 'Thiruvananthapuram', pincode: '695582'),
|
||||
_LocationItem(city: 'Neyyattinkara', district: 'Thiruvananthapuram', pincode: '695121'),
|
||||
_LocationItem(city: 'Attingal', district: 'Thiruvananthapuram', pincode: '695101'),
|
||||
_LocationItem(city: 'Kochi', district: 'Ernakulam', pincode: '682001'),
|
||||
_LocationItem(city: 'Ernakulam', district: 'Ernakulam', pincode: '682011'),
|
||||
_LocationItem(city: 'Aluva', district: 'Ernakulam', pincode: '683101'),
|
||||
_LocationItem(city: 'Kakkanad', district: 'Ernakulam', pincode: '682030'),
|
||||
_LocationItem(city: 'Fort Kochi', district: 'Ernakulam', pincode: '682001'),
|
||||
_LocationItem(city: 'Kozhikode', district: 'Kozhikode', pincode: '673001'),
|
||||
_LocationItem(city: 'Feroke', district: 'Kozhikode', pincode: '673631'),
|
||||
_LocationItem(city: 'Kollam', district: 'Kollam', pincode: '691001'),
|
||||
_LocationItem(city: 'Karunagappally', district: 'Kollam', pincode: '690518'),
|
||||
_LocationItem(city: 'Thrissur', district: 'Thrissur', pincode: '680001'),
|
||||
_LocationItem(city: 'Chavakkad', district: 'Thrissur', pincode: '680506'),
|
||||
_LocationItem(city: 'Guruvayoor', district: 'Thrissur', pincode: '680101'),
|
||||
_LocationItem(city: 'Irinjalakuda', district: 'Thrissur', pincode: '680121'),
|
||||
_LocationItem(city: 'Kannur', district: 'Kannur', pincode: '670001'),
|
||||
_LocationItem(city: 'Thalassery', district: 'Kannur', pincode: '670101'),
|
||||
_LocationItem(city: 'Alappuzha', district: 'Alappuzha', pincode: '688001'),
|
||||
_LocationItem(city: 'Cherthala', district: 'Alappuzha', pincode: '688524'),
|
||||
_LocationItem(city: 'Palakkad', district: 'Palakkad', pincode: '678001'),
|
||||
_LocationItem(city: 'Ottapalam', district: 'Palakkad', pincode: '679101'),
|
||||
_LocationItem(city: 'Malappuram', district: 'Malappuram', pincode: '676505'),
|
||||
_LocationItem(city: 'Manjeri', district: 'Malappuram', pincode: '676121'),
|
||||
_LocationItem(city: 'Tirur', district: 'Malappuram', pincode: '676101'),
|
||||
_LocationItem(city: 'Kottayam', district: 'Kottayam', pincode: '686001'),
|
||||
_LocationItem(city: 'Pala', district: 'Kottayam', pincode: '686575'),
|
||||
_LocationItem(city: 'Pathanamthitta', district: 'Pathanamthitta', pincode: '689645'),
|
||||
_LocationItem(city: 'Idukki', district: 'Idukki', pincode: '685602'),
|
||||
_LocationItem(city: 'Munnar', district: 'Idukki', pincode: '685612'),
|
||||
_LocationItem(city: 'Wayanad', district: 'Wayanad', pincode: '673121'),
|
||||
_LocationItem(city: 'Kalpetta', district: 'Wayanad', pincode: '673121'),
|
||||
_LocationItem(city: 'Kasaragod', district: 'Kasaragod', pincode: '671121'),
|
||||
_LocationItem(city: 'Whitefield', district: 'Bengaluru', pincode: '560066'),
|
||||
_LocationItem(city: 'Bengaluru', district: 'Karnataka', pincode: '560001'),
|
||||
];
|
||||
/// Searchable location database – loaded from assets/data/kerala_pincodes.json.
|
||||
List<_LocationItem> _locationDb = [];
|
||||
bool _pinsLoaded = false;
|
||||
|
||||
List<_LocationItem> _searchResults = [];
|
||||
bool _showSearchResults = false;
|
||||
bool _loadingLocation = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadKeralaData();
|
||||
}
|
||||
|
||||
Future<void> _loadKeralaData() async {
|
||||
if (_pinsLoaded) return;
|
||||
try {
|
||||
final jsonStr = await rootBundle.loadString('assets/data/kerala_pincodes.json');
|
||||
final List<dynamic> list = jsonDecode(jsonStr);
|
||||
final loaded = list.map((e) => _LocationItem(
|
||||
city: e['city'] as String,
|
||||
district: e['district'] as String?,
|
||||
pincode: e['pincode'] as String?,
|
||||
)).toList();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_locationDb = loaded;
|
||||
_pinsLoaded = true;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// fallback: keep empty list, search won't crash
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
|
||||
96
lib/widgets/eventify_bottom_sheet.dart
Normal file
96
lib/widgets/eventify_bottom_sheet.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void showEventifyBottomSheet(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required Widget child,
|
||||
double initialSize = 0.5,
|
||||
double minSize = 0.3,
|
||||
double maxSize = 0.9,
|
||||
bool isDismissible = true,
|
||||
}) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
isDismissible: isDismissible,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => DraggableScrollableSheet(
|
||||
initialChildSize: initialSize,
|
||||
minChildSize: minSize,
|
||||
maxChildSize: maxSize,
|
||||
expand: false,
|
||||
builder: (_, scrollController) => _EventifyBottomSheetContent(
|
||||
title: title,
|
||||
child: child,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _EventifyBottomSheetContent extends StatelessWidget {
|
||||
const _EventifyBottomSheetContent({
|
||||
required this.title,
|
||||
required this.child,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final Widget child;
|
||||
final ScrollController scrollController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF0F172A),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white54),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white12, height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/widgets/glass_card.dart
Normal file
53
lib/widgets/glass_card.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class GlassCard extends StatelessWidget {
|
||||
const GlassCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
this.margin,
|
||||
this.borderRadius = 16,
|
||||
this.blur = 10,
|
||||
this.backgroundColor,
|
||||
this.borderColor,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double borderRadius;
|
||||
final double blur;
|
||||
final Color? backgroundColor;
|
||||
final Color? borderColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveBackground =
|
||||
backgroundColor ?? const Color(0xFF1E293B).withOpacity(0.6);
|
||||
final effectiveBorder =
|
||||
borderColor ?? Colors.white.withOpacity(0.08);
|
||||
|
||||
Widget card = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveBackground,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: Border.all(color: effectiveBorder, width: 1),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (margin != null) {
|
||||
return Container(margin: margin, child: card);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
117
lib/widgets/tier_avatar_ring.dart
Normal file
117
lib/widgets/tier_avatar_ring.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
|
||||
class TierAvatarRing extends StatelessWidget {
|
||||
final String username;
|
||||
final String tier;
|
||||
final double size;
|
||||
final bool showDiceBear;
|
||||
final String? imageUrl;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
static const Map<String, Color> _tierColors = {
|
||||
'Bronze': Color(0xFFFED7AA),
|
||||
'Silver': Color(0xFFE2E8F0),
|
||||
'Gold': Color(0xFFFEF3C7),
|
||||
'Platinum': Color(0xFFEDE9FE),
|
||||
'Diamond': Color(0xFFE0E7FF),
|
||||
};
|
||||
|
||||
static const Color _fallbackColor = Color(0xFF475569);
|
||||
|
||||
const TierAvatarRing({
|
||||
super.key,
|
||||
required this.username,
|
||||
required this.tier,
|
||||
this.size = 48.0,
|
||||
this.showDiceBear = true,
|
||||
this.imageUrl,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
Color get _ringColor => _tierColors[tier] ?? _fallbackColor;
|
||||
|
||||
String get _avatarUrl {
|
||||
if (imageUrl != null && imageUrl!.isNotEmpty) {
|
||||
return imageUrl!;
|
||||
}
|
||||
return 'https://api.dicebear.com/9.x/notionists/svg?seed=$username';
|
||||
}
|
||||
|
||||
Widget _buildAvatar() {
|
||||
final double radius = size / 2 - 5;
|
||||
|
||||
if (!showDiceBear) {
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: const Color(0xFF1E293B),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: Colors.white54,
|
||||
size: size * 0.5,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: _avatarUrl,
|
||||
imageBuilder: (context, imageProvider) => CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundImage: imageProvider,
|
||||
),
|
||||
placeholder: (context, url) => CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: const Color(0xFF1E293B),
|
||||
child: SizedBox(
|
||||
width: size * 0.4,
|
||||
height: size * 0.4,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white38,
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: const Color(0xFF1E293B),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
color: Colors.white54,
|
||||
size: size * 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Color ringColor = _ringColor;
|
||||
final double containerSize = size + 6;
|
||||
|
||||
final Widget ring = Container(
|
||||
width: containerSize,
|
||||
height: containerSize,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: ringColor, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ringColor.withOpacity(0.4),
|
||||
blurRadius: 8,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(child: _buildAvatar()),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: ring,
|
||||
);
|
||||
}
|
||||
|
||||
return ring;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user