feat(share): rebuild share rank with dart:ui Canvas generator

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>
This commit is contained in:
2026-04-08 20:19:49 +05:30
parent bbef5b376d
commit 479fe5e119
3 changed files with 907 additions and 374 deletions

View File

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

View File

@@ -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<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)],
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)),
),
],
);
}
}