Replace RepaintBoundary widget capture approach with a pure dart:ui PictureRecorder + Canvas implementation. - Add share_card_generator.dart: generates 1080×1920 PNG via Canvas without embedding any widget in the tree - Remove share_rank_card.dart (widget approach no longer needed) - Remove GlobalKey, _buildHiddenShareCard, RepaintBoundary, _fmtEp from profile_screen.dart - Simplify desktop + mobile Stacks to direct ScrollViews - Fix Android GPU compositing timing crash (no retry needed) - Add avatarImage.dispose() to prevent GPU memory leak - Guard byteData null return with StateError - Replace MaterialIcons bolt with Unicode ⚡ (tree-shake safe) - Align tier in share text with tier rendered on card Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
548 lines
16 KiB
Dart
548 lines
16 KiB
Dart
// 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 = <String, _TierTheme>{
|
||
'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<Color> 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<Uint8List> 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<ui.Image?> _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();
|
||
}
|