// 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(); }