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,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),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user