diff --git a/lib/features/share/share_card_generator.dart b/lib/features/share/share_card_generator.dart new file mode 100644 index 0000000..1742fba --- /dev/null +++ b/lib/features/share/share_card_generator.dart @@ -0,0 +1,547 @@ +// lib/features/share/share_card_generator.dart +// +// Pure dart:ui Canvas generator — produces a 1080×1920 PNG story card +// without embedding any widget in the tree. Drop-in replacement for +// the old RepaintBoundary + ShareRankCard approach. + +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +// ── Tier theme data (ported from share_rank_card.dart) ───────────────────── + +const _tierThemes = { + 'Bronze': _TierTheme( + stops: [Color(0xFF92400E), Color(0xFFB45309), Color(0xFFD97706)], + ring: Color(0xFFD97706), + ), + 'Silver': _TierTheme( + stops: [Color(0xFF334155), Color(0xFF475569), Color(0xFF64748B)], + ring: Color(0xFF94A3B8), + ), + 'Gold': _TierTheme( + stops: [Color(0xFF78350F), Color(0xFF92400E), Color(0xFFB45309)], + ring: Color(0xFFF59E0B), + ), + 'Platinum': _TierTheme( + stops: [Color(0xFF4C1D95), Color(0xFF5B21B6), Color(0xFF7C3AED)], + ring: Color(0xFF8B5CF6), + ), + 'Diamond': _TierTheme( + stops: [Color(0xFF312E81), Color(0xFF4338CA), Color(0xFF6366F1)], + ring: Color(0xFF6366F1), + ), +}; + +class _TierTheme { + final List stops; + final Color ring; + const _TierTheme({required this.stops, required this.ring}); +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/// Generates a 1080×1920 PNG share card entirely via dart:ui Canvas. +/// Returns raw PNG bytes ready for [Share.shareXFiles]. +Future generateShareCardPng({ + required String username, + required String tier, + required int lifetimeEp, + required int currentEp, + required int rewardPoints, + String? eventifyId, + String? district, + String? imageUrl, +}) async { + const double w = 1080; + const double h = 1920; + + // Resolve tier theme + final capTier = tier.isEmpty + ? 'Bronze' + : (tier[0].toUpperCase() + tier.substring(1).toLowerCase()); + final theme = _tierThemes[capTier] ?? _tierThemes['Bronze']!; + + // Load avatar (if available) + ui.Image? avatarImage; + if (imageUrl != null && imageUrl.isNotEmpty) { + avatarImage = await _loadNetworkImage(imageUrl); + } + + // ── Draw ──────────────────────────────────────────────────────────────── + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder, const Rect.fromLTWH(0, 0, w, h)); + + // Layout constants (all at 3x of the original 360×640 widget) + const headerH = 894.0; // flex 45 of (1920-132) + const panelH = 894.0; // flex 45 + const footerH = 132.0; // 44 * 3 + const panelTop = headerH; + const footerTop = panelTop + panelH; + const cornerR = 84.0; // 28 * 3 + const pad = 60.0; // 20 * 3 + + // 1. Gradient header background + final gradientPaint = Paint() + ..shader = ui.Gradient.linear( + const Offset(w / 2, 0), + Offset(w / 2, headerH), + theme.stops, + [0.0, 0.5, 1.0], + ); + canvas.drawRect(const Rect.fromLTWH(0, 0, w, headerH), gradientPaint); + + // 2. White panel (rounded top corners) + final panelRRect = RRect.fromRectAndCorners( + const Rect.fromLTWH(0, panelTop, w, panelH), + topLeft: const Radius.circular(cornerR), + topRight: const Radius.circular(cornerR), + ); + canvas.drawRRect(panelRRect, Paint()..color = Colors.white); + + // 3. Footer + canvas.drawRect( + const Rect.fromLTWH(0, footerTop, w, footerH), + Paint()..color = const Color(0xFF0F45CF), + ); + + // ── Header content ──────────────────────────────────────────────────── + + // Avatar + const double avatarSize = 228; // 76 * 3 + const double ringGap = 9; // 3 * 3 + const double ringWidth = 15; // 5 * 3 + const double totalSize = avatarSize + (ringGap + ringWidth) * 2; + const double avatarCenterY = 340; + const avatarCenter = Offset(w / 2, avatarCenterY); + + // Draw ring + final ringPaint = Paint() + ..color = theme.ring + ..style = PaintingStyle.stroke + ..strokeWidth = ringWidth; + canvas.drawCircle(avatarCenter, totalSize / 2 - ringWidth / 2, ringPaint); + + // Draw avatar image or initials + const double avatarRadius = avatarSize / 2; + if (avatarImage != null) { + canvas.save(); + final clipPath = Path() + ..addOval(Rect.fromCircle(center: avatarCenter, radius: avatarRadius)); + canvas.clipPath(clipPath); + final src = Rect.fromLTWH( + 0, + 0, + avatarImage.width.toDouble(), + avatarImage.height.toDouble(), + ); + final dst = Rect.fromCircle(center: avatarCenter, radius: avatarRadius); + canvas.drawImageRect(avatarImage, src, dst, Paint()); + canvas.restore(); + } else { + // Initials fallback + canvas.save(); + final clipPath = Path() + ..addOval(Rect.fromCircle(center: avatarCenter, radius: avatarRadius)); + canvas.clipPath(clipPath); + canvas.drawCircle( + avatarCenter, + avatarRadius, + Paint()..color = Colors.white.withValues(alpha: 0.25), + ); + final initials = username.length >= 2 + ? username.substring(0, 2).toUpperCase() + : username.toUpperCase(); + final tp = _layoutText( + initials, + fontSize: avatarSize * 0.32, + fontWeight: FontWeight.w800, + color: Colors.white, + ); + tp.paint( + canvas, + Offset( + avatarCenter.dx - tp.width / 2, + avatarCenter.dy - tp.height / 2, + ), + ); + canvas.restore(); + } + + // Username (below avatar) + final displayName = + username.length > 20 ? username.substring(0, 20) : username; + final userTp = _layoutText( + displayName, + fontSize: 66, // 22 * 3 + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.9, + maxWidth: w - pad * 2, + ); + userTp.paint( + canvas, + Offset((w - userTp.width) / 2, avatarCenterY + totalSize / 2 + 30), + ); + + // Tier badge pill + final tierLabel = tier.isEmpty ? 'CONTRIBUTOR' : tier.toUpperCase(); + final badgeText = '\u2605 $tierLabel EXPLORER'; + final badgeTp = _layoutText( + badgeText, + fontSize: 33, // 11 * 3 + fontWeight: FontWeight.w700, + color: Colors.white, + letterSpacing: 1.5, + ); + const badgePadH = 42.0; // 14 * 3 + const badgePadV = 15.0; // 5 * 3 + final badgeW = badgeTp.width + badgePadH * 2; + final badgeH = badgeTp.height + badgePadV * 2; + final badgeY = + avatarCenterY + totalSize / 2 + 30 + userTp.height + 18; + final badgeRRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(w / 2, badgeY + badgeH / 2), + width: badgeW, + height: badgeH, + ), + const Radius.circular(60), + ); + canvas.drawRRect( + badgeRRect, + Paint()..color = Colors.black.withValues(alpha: 0.35), + ); + badgeTp.paint( + canvas, + Offset((w - badgeTp.width) / 2, badgeY + badgePadV), + ); + + // ── White panel content ─────────────────────────────────────────────── + + double cy = panelTop + pad; // running y cursor inside panel + + // Lifetime EP hero card + const heroCardH = 195.0; // approximate height for label + number + subtitle + final heroRect = RRect.fromRectAndRadius( + Rect.fromLTWH(pad, cy, w - pad * 2, heroCardH), + const Radius.circular(42), // 14 * 3 + ); + final heroBgPaint = Paint() + ..shader = ui.Gradient.linear( + Offset(pad, cy), + Offset(w - pad, cy), + [ + theme.stops.first.withValues(alpha: 0.12), + theme.stops.last.withValues(alpha: 0.06), + ], + ); + canvas.drawRRect(heroRect, heroBgPaint); + + // "LIFETIME EP ⚡" + const heroInnerPad = 48.0; // 16 * 3 + const heroInnerPadV = 42.0; // 14 * 3 + final labelTp = _layoutText( + 'LIFETIME EP \u26A1', + fontSize: 30, // 10 * 3 + fontWeight: FontWeight.w600, + color: const Color(0xFF64748B), + letterSpacing: 1.5, + ); + labelTp.paint(canvas, Offset(pad + heroInnerPad, cy + heroInnerPadV)); + + // Big EP number + final bigNumTp = _layoutText( + formatEp(lifetimeEp), + fontSize: 108, // 36 * 3 + fontWeight: FontWeight.w900, + color: theme.stops.first, + ); + bigNumTp.paint( + canvas, + Offset(pad + heroInnerPad, cy + heroInnerPadV + labelTp.height + 12), + ); + + // "Eventify Points earned" + final subTp = _layoutText( + 'Eventify Points earned', + fontSize: 33, // 11 * 3 + color: const Color(0xFF94A3B8), + ); + subTp.paint( + canvas, + Offset( + pad + heroInnerPad, + cy + heroInnerPadV + labelTp.height + 12 + bigNumTp.height + 3, + ), + ); + + cy += heroCardH + 30; // 10 * 3 gap + + // ── Liquid EP + Reward Points side-by-side pills ────────────────────── + const pillGap = 24.0; // 8 * 3 + final pillW = (w - pad * 2 - pillGap) / 2; + const pillH = 120.0; + + // Left pill — Liquid EP + _drawStatPill( + canvas, + x: pad, + y: cy, + width: pillW, + height: pillH, + emoji: '\u26A1', + label: 'LIQUID EP', + value: formatEp(currentEp), + bgColor: const Color(0xFFEFF6FF), + textColor: const Color(0xFF1D4ED8), + ); + + // Right pill — Reward Points + _drawStatPill( + canvas, + x: pad + pillW + pillGap, + y: cy, + width: pillW, + height: pillH, + emoji: '\uD83C\uDFC6', + label: 'REWARD POINTS', + value: formatEp(rewardPoints), + bgColor: const Color(0xFFFFFBEB), + textColor: const Color(0xFF92400E), + ); + + cy += pillH + 36; // 12 * 3 + + // ── Dashed divider ──────────────────────────────────────────────────── + final dashPaint = Paint() + ..color = const Color(0xFFE2E8F0) + ..strokeWidth = 3; + const dashW = 15.0; + const dashGap = 15.0; + double dx = pad; + while (dx < w - pad) { + canvas.drawLine( + Offset(dx, cy), + Offset((dx + dashW).clamp(0, w - pad), cy), + dashPaint, + ); + dx += dashW + dashGap; + } + + cy += 30; // 10 * 3 + + // ── CTA text ────────────────────────────────────────────────────────── + final ctaTp = _layoutText( + 'Join me on Eventify Plus!', + fontSize: 42, // 14 * 3 + fontWeight: FontWeight.w800, + color: const Color(0xFF0F172A), + ); + ctaTp.paint(canvas, Offset((w - ctaTp.width) / 2, cy)); + cy += ctaTp.height + 6; + + final ctaSubTp = _layoutText( + 'Discover events. Earn rewards.', + fontSize: 33, // 11 * 3 + color: const Color(0xFF64748B), + ); + ctaSubTp.paint(canvas, Offset((w - ctaSubTp.width) / 2, cy)); + cy += ctaSubTp.height; + + // ── Optional eventifyId pill ────────────────────────────────────────── + if (eventifyId != null && eventifyId.isNotEmpty) { + cy += 30; + final idTp = _layoutText( + eventifyId, + fontSize: 33, + fontWeight: FontWeight.w700, + color: const Color(0xFF1D4ED8), + fontFamily: 'monospace', + ); + final idPillW = idTp.width + 72; // 12*3 * 2 + final idPillH = idTp.height + 24; // 4*3 * 2 + final idRRect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: Offset(w / 2, cy + idPillH / 2), + width: idPillW, + height: idPillH, + ), + const Radius.circular(36), + ); + canvas.drawRRect(idRRect, Paint()..color = const Color(0xFFEFF6FF)); + idTp.paint(canvas, Offset((w - idTp.width) / 2, cy + 12)); + cy += idPillH; + } + + // ── Optional district ───────────────────────────────────────────────── + if (district != null && district.isNotEmpty) { + cy += 18; // 6 * 3 + final distTp = _layoutText( + '\uD83D\uDCCD $district', + fontSize: 33, + color: const Color(0xFF64748B), + ); + distTp.paint(canvas, Offset((w - distTp.width) / 2, cy)); + } + + // ── Footer content ──────────────────────────────────────────────────── + final boltTp = _layoutText( + '\u26A1', + fontSize: 42, + color: Colors.white, + ); + + final brandTp = _layoutText( + 'E V E N T I F Y', + fontSize: 39, // 13 * 3 + fontWeight: FontWeight.w900, + color: Colors.white, + letterSpacing: 9, + ); + + final urlTp = _layoutText( + 'eventifyplus.com', + fontSize: 30, // 10 * 3 + fontWeight: FontWeight.w500, + color: const Color(0xFF93C5FD), + ); + + // Center the row: bolt + 18px + brand + 36px + url + const gap1 = 18.0; + const gap2 = 36.0; + final totalRowW = + boltTp.width + gap1 + brandTp.width + gap2 + urlTp.width; + final rowX = (w - totalRowW) / 2; + final footerCenterY = footerTop + footerH / 2; + + boltTp.paint( + canvas, + Offset(rowX, footerCenterY - boltTp.height / 2), + ); + brandTp.paint( + canvas, + Offset(rowX + boltTp.width + gap1, footerCenterY - brandTp.height / 2), + ); + urlTp.paint( + canvas, + Offset( + rowX + boltTp.width + gap1 + brandTp.width + gap2, + footerCenterY - urlTp.height / 2, + ), + ); + + // ── Finalize ────────────────────────────────────────────────────────── + avatarImage?.dispose(); + final picture = recorder.endRecording(); + final image = await picture.toImage(w.toInt(), h.toInt()); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + if (byteData == null) { + throw StateError('Failed to encode share card to PNG'); + } + return byteData.buffer.asUint8List(); +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/// Loads a network image as a [ui.Image] for Canvas drawing. +Future _loadNetworkImage(String url) async { + try { + final response = + await http.get(Uri.parse(url)).timeout(const Duration(seconds: 5)); + if (response.statusCode != 200) return null; + final codec = await ui.instantiateImageCodec(response.bodyBytes); + final frame = await codec.getNextFrame(); + return frame.image; + } catch (e) { + debugPrint('Share card avatar load failed: $e'); + return null; + } +} + +/// Creates and lays out a [TextPainter] for Canvas drawing. +TextPainter _layoutText( + String text, { + required double fontSize, + FontWeight fontWeight = FontWeight.w400, + Color color = Colors.black, + double letterSpacing = 0, + String fontFamily = 'Gilroy', + double maxWidth = 1080, +}) { + final tp = TextPainter( + text: TextSpan( + text: text, + style: TextStyle( + fontFamily: fontFamily, + fontSize: fontSize, + fontWeight: fontWeight, + color: color, + letterSpacing: letterSpacing, + height: 1.2, + ), + ), + textDirection: TextDirection.ltr, + textAlign: TextAlign.left, + )..layout(maxWidth: maxWidth); + return tp; +} + +/// Draws a stat pill (e.g. Liquid EP, Reward Points). +void _drawStatPill( + Canvas canvas, { + required double x, + required double y, + required double width, + required double height, + required String emoji, + required String label, + required String value, + required Color bgColor, + required Color textColor, +}) { + const r = 36.0; + const pad = 36.0; + + canvas.drawRRect( + RRect.fromRectAndRadius(Rect.fromLTWH(x, y, width, height), const Radius.circular(r)), + Paint()..color = bgColor, + ); + + final labelTp = _layoutText( + '$emoji $label', + fontSize: 27, // 9 * 3 + fontWeight: FontWeight.w600, + color: textColor, + letterSpacing: 0.9, + maxWidth: width - pad * 2, + ); + labelTp.paint(canvas, Offset(x + pad, y + pad * 0.6)); + + final valTp = _layoutText( + value, + fontSize: 60, // 20 * 3 + fontWeight: FontWeight.w900, + color: textColor, + maxWidth: width - pad * 2, + ); + valTp.paint(canvas, Offset(x + pad, y + pad * 0.6 + labelTp.height + 12)); +} + +/// Formats a number with commas (e.g. 1234 → "1,234", 1234567 → "1.2M"). +String formatEp(int n) { + if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; + if (n >= 1000) { + final s = n.toString(); + final buf = StringBuffer(); + for (var i = 0; i < s.length; i++) { + if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); + buf.write(s[i]); + } + return buf.toString(); + } + return n.toString(); +} diff --git a/lib/features/share/share_rank_card.dart b/lib/features/share/share_rank_card.dart deleted file mode 100644 index fe3bf49..0000000 --- a/lib/features/share/share_rank_card.dart +++ /dev/null @@ -1,198 +0,0 @@ -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 createState() => _ShareRankCardState(); -} - -class _ShareRankCardState extends State { - 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 get _gradient { - return _tierGradients[widget.tier] ?? [const Color(0xFF0F172A), const Color(0xFF1E293B)]; - } - - Future _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)], - subject: 'My Eventify Rank', - text: 'I\'m a ${widget.tier.toUpperCase()} Explorer on Eventify Plus! ${widget.ep} EP earned. Let\'s connect on the platform for more.\n\nhttps://app.eventifyplus.com', - ); - } 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)), - ), - ], - ); - } -} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 90ccbb4..fe90ab1 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -8,6 +8,8 @@ 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:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:provider/provider.dart'; @@ -20,10 +22,11 @@ import '../widgets/tier_avatar_ring.dart'; import 'learn_more_screen.dart'; import 'settings_screen.dart'; import '../core/api/api_endpoints.dart'; +import '../core/api/api_client.dart'; import '../core/app_decoration.dart'; import '../core/constants.dart'; import '../widgets/landscape_section_header.dart'; -import '../features/share/share_rank_card.dart'; +import '../features/share/share_card_generator.dart'; import '../core/analytics/posthog_service.dart'; class ProfileScreen extends StatefulWidget { @@ -35,6 +38,7 @@ class ProfileScreen extends StatefulWidget { class _ProfileScreenState extends State with SingleTickerProviderStateMixin { + final ApiClient _apiClient = ApiClient(); String _username = ''; String _email = 'not provided'; String _profileImage = ''; @@ -44,6 +48,9 @@ class _ProfileScreenState extends State DateTime? _districtChangedAt; final ImagePicker _picker = ImagePicker(); + // Share rank + bool _sharingRank = false; + // 14 Kerala districts static const List _districts = [ 'Thiruvananthapuram', 'Kollam', 'Pathanamthitta', 'Alappuzha', @@ -180,6 +187,10 @@ class _ProfileScreenState extends State _profileImage = prefs.getString(profileImageKey) ?? prefs.getString('profileImage') ?? ''; + // Always fetch fresh data from server (fire-and-forget) + // Ensures profile photo, district, and cooldown are always up-to-date + _fetchAndCacheStatusData(prefs, profileImageKey); + // AUTH-003/PROF-001: Eventify ID _eventifyId = prefs.getString('eventify_id'); @@ -199,6 +210,48 @@ class _ProfileScreenState extends State if (mounted) setState(() {}); } + /// Fetches fresh data from /user/status/ on every profile open. + /// Updates profile photo, district, and district_changed_at. + Future _fetchAndCacheStatusData(SharedPreferences prefs, String profileImageKey) async { + try { + final res = await _apiClient.post(ApiEndpoints.status); + + // Profile photo + final raw = res['profile_photo']?.toString() ?? ''; + if (raw.isNotEmpty && !raw.contains('default.png')) { + final url = raw.startsWith('http') ? raw : 'https://em.eventifyplus.com$raw'; + await prefs.setString(profileImageKey, url); + await prefs.setString('profileImage', url); + if (mounted) setState(() => _profileImage = url); + } + + // Eventify ID + final eventifyId = res['eventify_id']?.toString() ?? ''; + if (eventifyId.isNotEmpty) { + await prefs.setString('eventify_id', eventifyId); + if (mounted) setState(() => _eventifyId = eventifyId); + } + + // District + cooldown timestamp + final districtFromServer = res['district']?.toString() ?? ''; + final changedAtStr = res['district_changed_at']?.toString() ?? ''; + if (districtFromServer.isNotEmpty) { + await prefs.setString('district', districtFromServer); + } + if (changedAtStr.isNotEmpty) { + await prefs.setString('district_changed_at', changedAtStr); + } + if (mounted) { + setState(() { + if (districtFromServer.isNotEmpty) _district = districtFromServer; + if (changedAtStr.isNotEmpty) _districtChangedAt = DateTime.tryParse(changedAtStr); + }); + } + } catch (_) { + // Non-critical — silently ignore if status call fails + } + } + Future _loadEventsForProfile([SharedPreferences? prefs]) async { _ongoingEvents = []; @@ -341,26 +394,24 @@ class _ProfileScreenState extends State 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}), + final res = await _apiClient.post( + ApiEndpoints.updateProfile, + body: {'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; - }); - } + final savedDistrict = res['district']?.toString() ?? district; + final changedAtStr = res['district_changed_at']?.toString() ?? ''; + final now = DateTime.now(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('district', savedDistrict); + final changedAt = changedAtStr.isNotEmpty + ? (DateTime.tryParse(changedAtStr) ?? now) + : now; + await prefs.setString('district_changed_at', changedAt.toIso8601String()); + if (mounted) { + setState(() { + _district = savedDistrict; + _districtChangedAt = changedAt; + }); } } catch (e) { debugPrint('District update error: $e'); @@ -371,6 +422,198 @@ class _ProfileScreenState extends State } } + /// Generates the share card via dart:ui Canvas and shares as PNG + caption. + Future _shareRank(String tier, int lifetimeEp) async { + if (_sharingRank) return; + setState(() => _sharingRank = true); + try { + final imageUrl = _profileImage.isNotEmpty && + !_profileImage.contains('default.png') + ? (_profileImage.startsWith('http') + ? _profileImage + : 'https://em.eventifyplus.com$_profileImage') + : null; + + final gam = context.read(); + final p = gam.profile; + final stats = gam.currentUserStats; + + final resolvedTier = stats?.level ?? _userTier ?? tier; + final resolvedEp = p?.lifetimeEp ?? lifetimeEp; + + final bytes = await generateShareCardPng( + username: _username, + tier: resolvedTier, + lifetimeEp: resolvedEp, + currentEp: p?.currentEp ?? 0, + rewardPoints: p?.currentRp ?? 0, + eventifyId: _eventifyId, + district: _district, + imageUrl: imageUrl, + ); + + final shareText = + "I'm a ${resolvedTier.toUpperCase()} Explorer on Eventify Plus! " + "${formatEp(resolvedEp)} EP earned. " + "Let's connect on the platform for more.\nhttps://app.eventifyplus.com"; + + if (kIsWeb) { + try { + final xfile = XFile.fromData( + bytes, + name: 'eventify-rank.png', + mimeType: 'image/png', + ); + await Share.shareXFiles( + [xfile], + subject: 'My Eventify Rank', + text: shareText, + ); + } catch (_) { + await Clipboard.setData(ClipboardData(text: shareText)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Caption copied! Save the image and paste caption when sharing.'), + duration: Duration(seconds: 5), + ), + ); + } + } + } else { + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/eventify_rank.png'); + await file.writeAsBytes(bytes); + await Share.shareXFiles( + [XFile(file.path)], + subject: 'My Eventify Rank', + text: shareText, + ); + } + } catch (e, st) { + debugPrint('_shareRank error: $e\n$st'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Share failed: $e')), + ); + } + } finally { + if (mounted) setState(() => _sharingRank = false); + } + } + + /// Shows a bottom sheet with the 14 Kerala district pills. + /// Used both from the header district row and from the Edit Profile sheet. + void _showDistrictPicker() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) { + final theme = Theme.of(ctx); + return StatefulBuilder( + builder: (ctx2, setInner) { + return Container( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 32), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 48, + height: 6, + decoration: BoxDecoration( + color: theme.dividerColor, + borderRadius: BorderRadius.circular(6), + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Select Your District', + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + 'Used for leaderboard rankings. Can be changed once every 6 months.', + style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor), + ), + const SizedBox(height: 12), + if (_districtLocked) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + const Icon(Icons.lock_outline, size: 14, color: Colors.amber), + const SizedBox(width: 6), + Text( + 'Locked until $_districtNextChange', + style: const TextStyle(fontSize: 12, color: Colors.amber, fontWeight: FontWeight.w600), + ), + ], + ), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: _districts.map((d) { + final isSelected = _district == d; + return GestureDetector( + onTap: _districtLocked + ? null + : () async { + setInner(() {}); + Navigator.of(ctx2).pop(); + await _updateDistrict(d); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF3B82F6) + : (_districtLocked + ? theme.dividerColor.withOpacity(0.5) + : theme.cardColor), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isSelected + ? const Color(0xFF3B82F6) + : theme.dividerColor, + ), + ), + child: Text( + d, + style: TextStyle( + fontSize: 13, + color: isSelected + ? Colors.white + : (_districtLocked + ? theme.hintColor + : theme.textTheme.bodyMedium?.color), + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + }, + ); + } + Future _enterAssetPathDialog() async { final ctl = TextEditingController(text: _profileImage); final result = await showDialog( @@ -520,83 +763,37 @@ class _ProfileScreenState extends State ), const SizedBox(height: 20), - // PROF-002: District picker - Align( - alignment: Alignment.centerLeft, - child: Text( - 'District', - style: theme.textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, + // PROF-002: District — tap to open dedicated picker sheet + GestureDetector( + onTap: () { + Navigator.of(context).pop(); + _showDistrictPicker(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: theme.cardColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.dividerColor), ), - ), - ), - 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), + const Icon(Icons.location_on_outlined, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + _district ?? 'Tap to set district', + style: theme.textTheme.bodyMedium, + ), + ), + Icon( + _districtLocked ? Icons.lock_outline : Icons.chevron_right, + size: 18, + color: _districtLocked ? Colors.amber : theme.hintColor, ), ], ), ), - - // 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), ], @@ -616,11 +813,13 @@ class _ProfileScreenState extends State 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')) { - imageUrl = path; + if (path.isNotEmpty && !path.contains('default.png')) { + if (path.startsWith('http')) { + imageUrl = path; + } else if (path.startsWith('/media/')) { + imageUrl = 'https://em.eventifyplus.com$path'; + } } return TierAvatarRing( username: _username.isNotEmpty ? _username : _email, @@ -876,17 +1075,26 @@ class _ProfileScreenState extends State ), const SizedBox(height: 16), - // Location (District) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.location_on_outlined, color: Colors.white, size: 18), - const SizedBox(width: 4), - Text( - p?.district ?? _district ?? 'No district selected', - style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w400), - ), - ], + // Location (District) — tappable to open picker + GestureDetector( + onTap: _showDistrictPicker, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.location_on_outlined, color: Colors.white, size: 18), + const SizedBox(width: 4), + Text( + _district ?? 'Tap to set district', + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w400), + ), + const SizedBox(width: 4), + Icon( + _districtLocked ? Icons.lock_outline : Icons.edit_outlined, + color: Colors.white70, + size: 14, + ), + ], + ), ), if (_districtNextChange.isNotEmpty) Padding( @@ -935,23 +1143,11 @@ class _ProfileScreenState extends State ), const SizedBox(height: 12), _buildHeaderButton( - label: 'Share Rank', - icon: Icons.share_outlined, - onTap: () { - showDialog( - context: context, - builder: (_) => Dialog( - backgroundColor: Colors.transparent, - child: ShareRankCard( - username: _username, - tier: stats?.level ?? _userTier ?? '', - rank: stats?.rank ?? 0, - ep: p?.currentEp ?? 0, - rewardPoints: p?.currentRp ?? 0, - ), - ), - ); - }, + label: _sharingRank ? 'Generating...' : 'Share Rank', + icon: _sharingRank ? Icons.hourglass_top_outlined : Icons.share_outlined, + onTap: _sharingRank + ? () {} + : () => _shareRank(tier, p?.lifetimeEp ?? 0), ), ], ), @@ -1295,28 +1491,22 @@ class _ProfileScreenState extends State // Share rank card button Consumer( 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, - ), - ), - ); - }, + onTap: _sharingRank + ? null + : () => _shareRank( + gam.currentUserStats?.level ?? _userTier ?? 'Bronze', + gam.profile?.lifetimeEp ?? 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), + child: Icon( + _sharingRank ? Icons.hourglass_top_outlined : Icons.share_outlined, + color: Colors.white, + ), ), ), ), @@ -1934,35 +2124,37 @@ class _ProfileScreenState extends State Widget _buildDesktopLayout(BuildContext context, ThemeData theme) { return Scaffold( backgroundColor: theme.scaffoldBackgroundColor, - body: SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Full-width profile header + card (reuse existing widgets) - Stack( + body: Consumer( + builder: (context, gam, _) { + return SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildGradientHeader(context, 200), - Padding( - padding: const EdgeInsets.only(top: 130), - child: _buildProfileCard(context), + // Full-width profile header + card (reuse existing widgets) + Stack( + children: [ + _buildGradientHeader(context, 200), + Padding( + padding: const EdgeInsets.only(top: 130), + child: _buildProfileCard(context), + ), + ], ), + const SizedBox(height: 24), + // Ongoing Events (only if non-empty) + if (_ongoingEvents.isNotEmpty) + _buildDesktopEventSection( + context, + title: 'Ongoing Events', + events: _ongoingEvents, + faded: false, + ), + const SizedBox(height: 32), ], ), - const SizedBox(height: 24), - - // Ongoing Events (only if non-empty) - if (_ongoingEvents.isNotEmpty) - _buildDesktopEventSection( - context, - title: 'Ongoing Events', - events: _ongoingEvents, - faded: false, - ), - - const SizedBox(height: 32), - ], - ), + ); + }, ), ); } @@ -2237,24 +2429,16 @@ class _ProfileScreenState extends State // ── MOBILE layout ───────────────────────────────────────────────────── return Scaffold( - backgroundColor: const Color(0xFFF9FAFB), // Softer light bg + backgroundColor: const Color(0xFFF9FAFB), body: Consumer( builder: (context, gam, _) { return CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ - SliverToBoxAdapter( - child: _buildModernHeader(context, theme), - ), - SliverToBoxAdapter( - child: _buildModernStatCards(gam, theme), - ), - SliverToBoxAdapter( - child: _buildTierProgressCard(gam, theme), - ), - SliverToBoxAdapter( - child: _buildContributedEventsSection(gam, theme), - ), + SliverToBoxAdapter(child: _buildModernHeader(context, theme)), + SliverToBoxAdapter(child: _buildModernStatCards(gam, theme)), + SliverToBoxAdapter(child: _buildTierProgressCard(gam, theme)), + SliverToBoxAdapter(child: _buildContributedEventsSection(gam, theme)), ], ); },