diff --git a/assets/svg/icon_token_fire.svg b/assets/svg/icon_token_fire.svg new file mode 100644 index 0000000000..07e0b707b9 --- /dev/null +++ b/assets/svg/icon_token_fire.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/app/features/tokenized_communities/providers/trade_infrastructure_providers.r.dart b/lib/app/features/tokenized_communities/providers/trade_infrastructure_providers.r.dart index 178459afdb..257b4cca02 100644 --- a/lib/app/features/tokenized_communities/providers/trade_infrastructure_providers.r.dart +++ b/lib/app/features/tokenized_communities/providers/trade_infrastructure_providers.r.dart @@ -105,6 +105,12 @@ Future tradeCommunityTokenService( ); } +@riverpod +Future bondingCurveAddress(Ref ref) async { + final repository = await ref.watch(tradeCommunityTokenRepositoryProvider.future); + return repository.fetchBondingCurveAddress(); +} + @riverpod Future> supportedSwapTokens(Ref ref) async { final api = await ref.watch(tradeCommunityTokenApiProvider.future); diff --git a/lib/app/features/tokenized_communities/views/pages/holders/components/holder_tile.dart b/lib/app/features/tokenized_communities/views/pages/holders/components/holder_tile.dart index a2d573d3be..d109646f0d 100644 --- a/lib/app/features/tokenized_communities/views/pages/holders/components/holder_tile.dart +++ b/lib/app/features/tokenized_communities/views/pages/holders/components/holder_tile.dart @@ -10,6 +10,12 @@ import 'package:ion/app/utils/num.dart'; import 'package:ion/generated/assets.gen.dart'; import 'package:ion_token_analytics/ion_token_analytics.dart'; +enum RankBadgeType { + regular, + burning, + bondingCurve, +} + class BondingCurveHolderTile extends StatelessWidget { const BondingCurveHolderTile({ required this.holder, @@ -25,7 +31,32 @@ class BondingCurveHolderTile extends StatelessWidget { amountText: formatAmountCompactFromRaw(holder.position.amount), displayName: context.i18n.tokenized_community_bonding_curve, supplyShare: holder.position.supplyShare, - avatarUrl: Assets.svg.iconBondingCurveAvatar, + avatarUrl: holder.position.holder?.avatar, + badgeType: RankBadgeType.bondingCurve, + ); + } +} + +class BurningHolderTile extends StatelessWidget { + const BurningHolderTile({ + required this.holder, + super.key, + }); + + final TopHolder holder; + + @override + Widget build(BuildContext context) { + final holderAddress = holder.position.holder?.addresses?.ionConnect ?? ''; + + return HolderTile( + rank: holder.position.rank, + amountText: formatAmountCompactFromRaw(holder.position.amount), + displayName: holder.position.holder?.display ?? context.i18n.tokenized_community_burned, + supplyShare: holder.position.supplyShare, + avatarUrl: holder.position.holder?.avatar, + badgeType: RankBadgeType.burning, + address: holderAddress, ); } } @@ -77,6 +108,8 @@ class HolderTile extends StatelessWidget { this.avatarUrl, this.holderAddress, this.isXUser = false, + this.badgeType = RankBadgeType.regular, + this.address, super.key, }); @@ -90,6 +123,8 @@ class HolderTile extends StatelessWidget { final String? avatarUrl; final String? holderAddress; final bool isXUser; + final RankBadgeType badgeType; + final String? address; @override Widget build(BuildContext context) { @@ -111,7 +146,7 @@ class HolderTile extends StatelessWidget { Expanded( child: Row( children: [ - _RankBadge(rank: rank), + _RankBadge(rank: rank, type: badgeType), SizedBox(width: 12.0.s), HolderAvatar( imageUrl: avatarUrl, @@ -123,6 +158,7 @@ class HolderTile extends StatelessWidget { child: _NameAndAmount( name: displayName, handle: username, + address: address, isCreator: isCreator, verified: verified, amountText: amountText, @@ -150,22 +186,30 @@ class HolderTile extends StatelessWidget { } } -/// -/// rank 1 -> bonding curve -/// rank 2 -> 1st medal badge -/// rank 3 -> 2nd medal badge -/// rank 4 -> 3rd medal badge -/// rank n -> n-1 text badge -/// class _RankBadge extends StatelessWidget { - const _RankBadge({required this.rank}); + const _RankBadge({ + required this.rank, + required this.type, + }); final int rank; + final RankBadgeType type; @override Widget build(BuildContext context) { final colors = context.theme.appColors; - final isMedal = rank <= 4; + + final child = switch (type) { + RankBadgeType.burning => Assets.svg.iconTokenFire.icon(), + RankBadgeType.bondingCurve => Assets.svg.iconMemeBondingcurve.icon(), + RankBadgeType.regular => rank <= 3 + ? _MedalIcon(rank: rank) + : Text( + '$rank', + style: context.theme.appTextThemes.body.copyWith(color: colors.primaryAccent), + ), + }; + return Container( width: 30.0.s, height: 30.0.s, @@ -174,12 +218,7 @@ class _RankBadge extends StatelessWidget { borderRadius: BorderRadius.circular(10.0.s), ), alignment: Alignment.center, - child: isMedal - ? _MedalIcon(rank: rank) - : Text( - '${rank - 1}', - style: context.theme.appTextThemes.body.copyWith(color: colors.primaryAccent), - ), + child: child, ); } } @@ -192,10 +231,9 @@ class _MedalIcon extends StatelessWidget { @override Widget build(BuildContext context) { return switch (rank) { - 1 => Assets.svg.iconMemeBondingcurve, - 2 => Assets.svg.iconMeme1stplace, - 3 => Assets.svg.iconMeme2ndtplace, - 4 => Assets.svg.iconMeme3rdplace, + 1 => Assets.svg.iconMeme1stplace, + 2 => Assets.svg.iconMeme2ndtplace, + 3 => Assets.svg.iconMeme3rdplace, _ => throw UnimplementedError(), } .icon(); @@ -205,15 +243,17 @@ class _MedalIcon extends StatelessWidget { class _NameAndAmount extends StatelessWidget { const _NameAndAmount({ required this.name, - required this.handle, required this.amountText, required this.verified, required this.isCreator, + this.handle, + this.address, this.isXUser = true, }); final String name; final String? handle; + final String? address; final String amountText; final bool verified; final bool isCreator; @@ -256,7 +296,11 @@ class _NameAndAmount extends StatelessWidget { ], ), Text( - handle != null ? '$handle • $amountText' : amountText, + address != null + ? shortenAddress(address!) + : handle != null + ? '$handle • $amountText' + : amountText, maxLines: 1, overflow: TextOverflow.ellipsis, style: texts.caption.copyWith(color: colors.quaternaryText), diff --git a/lib/app/features/tokenized_communities/views/pages/holders/components/top_holders/top_holders.dart b/lib/app/features/tokenized_communities/views/pages/holders/components/top_holders/top_holders.dart index d18148448f..46464fac4d 100644 --- a/lib/app/features/tokenized_communities/views/pages/holders/components/top_holders/top_holders.dart +++ b/lib/app/features/tokenized_communities/views/pages/holders/components/top_holders/top_holders.dart @@ -3,12 +3,14 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/tokenized_communities/providers/trade_infrastructure_providers.r.dart'; import 'package:ion/app/features/tokenized_communities/views/pages/holders/components/holder_tile.dart'; import 'package:ion/app/features/tokenized_communities/views/pages/holders/components/top_holders/components/top_holders_empty.dart'; import 'package:ion/app/features/tokenized_communities/views/pages/holders/components/top_holders/components/top_holders_skeleton.dart'; import 'package:ion/app/features/tokenized_communities/views/pages/holders/providers/token_top_holders_provider.r.dart'; import 'package:ion/app/router/app_routes.gr.dart'; import 'package:ion/generated/assets.gen.dart'; +import 'package:ion_token_analytics/ion_token_analytics.dart'; const int holdersCountLimit = 5; @@ -141,6 +143,7 @@ class _TopHolderList extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final holdersAsync = ref.watch(tokenTopHoldersProvider(externalAddress, limit: holdersCountLimit)); + final boundingCurveAddress = ref.watch(bondingCurveAddressProvider).valueOrNull; return holdersAsync.when( data: (holders) { if (holders.isEmpty) { @@ -153,13 +156,18 @@ class _TopHolderList extends ConsumerWidget { padding: EdgeInsets.zero, itemCount: holders.length, itemBuilder: (context, index) { - if (index == 0) { + final holder = holders[index]; + + if (boundingCurveAddress != null && holder.isBoundingCurve(boundingCurveAddress)) { return BondingCurveHolderTile( - holder: holders[index], + holder: holder, ); } - final holder = holders[index]; + if (holder.isBurning) { + return BurningHolderTile(holder: holder); + } + return TopHolderTile(holder: holder); }, separatorBuilder: (context, index) => SizedBox(height: 4.0.s), diff --git a/lib/app/features/tokenized_communities/views/pages/holders/pages/holders_page.dart b/lib/app/features/tokenized_communities/views/pages/holders/pages/holders_page.dart index 161f8b94db..0642014587 100644 --- a/lib/app/features/tokenized_communities/views/pages/holders/pages/holders_page.dart +++ b/lib/app/features/tokenized_communities/views/pages/holders/pages/holders_page.dart @@ -8,6 +8,7 @@ import 'package:ion/app/components/scroll_view/load_more_builder.dart'; import 'package:ion/app/components/scroll_view/pull_to_refresh_builder.dart'; import 'package:ion/app/components/separated/separator.dart'; import 'package:ion/app/extensions/extensions.dart'; +import 'package:ion/app/features/tokenized_communities/providers/trade_infrastructure_providers.r.dart'; import 'package:ion/app/features/tokenized_communities/views/pages/holders/components/holder_tile.dart'; import 'package:ion/app/features/tokenized_communities/views/pages/holders/components/top_holders/components/top_holders_skeleton.dart'; import 'package:ion/app/features/tokenized_communities/views/pages/holders/providers/token_top_holders_provider.r.dart'; @@ -28,7 +29,7 @@ class HoldersPage extends HookConsumerWidget { final topHoldersProvider = tokenTopHoldersProvider(externalAddress, limit: 20); final topHoldersAsync = ref.watch(topHoldersProvider); final topHolders = topHoldersAsync.valueOrNull ?? const []; - + final boundingCurveAddress = ref.watch(bondingCurveAddressProvider).valueOrNull; return Scaffold( appBar: NavigationAppBar.screen( title: Text(context.i18n.holders, style: context.theme.appTextThemes.subtitle2), @@ -53,22 +54,32 @@ class HoldersPage extends HookConsumerWidget { itemBuilder: (context, index) { final topPadding = index == 0 ? 12.s : 7.s; final bottomPadding = 7.s; + final holder = topHolders[index]; - if (index == 0) { + if (boundingCurveAddress != null && + holder.isBoundingCurve(boundingCurveAddress)) { return _HoldersListPadding( topPadding: topPadding, bottomPadding: bottomPadding, child: BondingCurveHolderTile( - holder: topHolders[index], + holder: holder, ), ); } + if (holder.isBurning) { + return _HoldersListPadding( + topPadding: topPadding, + bottomPadding: bottomPadding, + child: BurningHolderTile(holder: holder), + ); + } + return _HoldersListPadding( topPadding: topPadding, bottomPadding: bottomPadding, child: TopHolderTile( - holder: topHolders[index], + holder: holder, ), ); }, diff --git a/lib/app/features/tokenized_communities/views/pages/holders/providers/token_top_holders_provider.r.dart b/lib/app/features/tokenized_communities/views/pages/holders/providers/token_top_holders_provider.r.dart index 3c9e32d9be..2ee6b3b10e 100644 --- a/lib/app/features/tokenized_communities/views/pages/holders/providers/token_top_holders_provider.r.dart +++ b/lib/app/features/tokenized_communities/views/pages/holders/providers/token_top_holders_provider.r.dart @@ -223,21 +223,16 @@ class TokenTopHolders extends _$TokenTopHolders { } final rank = item.position.rank; - final insertAt = (rank - 1).clamp(0, list.length); - list.insert(insertAt, item); - _normalizeRanks(list); - } - - void _normalizeRanks(List list) { - for (var i = 0; i < list.length; i++) { - final desiredRank = i + 1; - final current = list[i]; - if (current.position.rank != desiredRank) { - list[i] = current.copyWith( - position: current.position.copyWith(rank: desiredRank), - ); - } + if (rank == 0) { + // Count existing rank 0 items to insert after them + final rankZeroCount = list.where((h) => h.position.rank == 0).length; + list.insert(rankZeroCount, item); + } else { + // Count rank 0 items, then insert after them using rank - 1 + final rankZeroCount = list.where((h) => h.position.rank == 0).length; + final insertAt = (rankZeroCount + rank - 1).clamp(0, list.length); + list.insert(insertAt, item); } } diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 0a2632f463..66ba61235e 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -934,6 +934,7 @@ "tokenized_community_comments_empty": "كن أول من ينضم إلى المحادثة", "tokenized_community_comments_tab": "التعليقات", "tokenized_community_holders_tab": "الحائزون", + "tokenized_community_burned": "محروق", "token_comment_holders_only": "التعليقات متاحة فقط لحاملي الرموز.", "tokenized_community_not_available_description": "إنشاء الرموز غير متاح للمنشورات التي تم إنشاؤها مسبقًا.", "tokenized_community_not_available_title": "خطأ في المحتوى المرمز", diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 3503613724..ab9b0e207f 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -920,6 +920,7 @@ "tokenized_community_creator_token_live_title": "Токенът на създателя е АКТИВЕН!", "tokenized_community_creator_token_live_subtitle": "Поздравления, вашият токен на създателя вече е активен и достъпен за търговия от всички", "tokenized_community_holders_tab": "Притежатели", + "tokenized_community_burned": "Изгорени", "token_comment_holders_only": "Коментарите са достъпни само за притежатели на токени.", "tokenized_community_not_available_description": "Създаването на токени не е налично за вече създадени публикации.", "tokenized_community_not_available_title": "Грешка при токенизирано съдържание", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fdae60d6a1..6d318cca94 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -920,6 +920,7 @@ "tokenized_community_creator_token_live_title": "Creator-Token ist LIVE!", "tokenized_community_creator_token_live_subtitle": "Herzlichen Glückwunsch, Ihr Creator-Token ist jetzt live und für jeden handelbar", "tokenized_community_holders_tab": "Inhaber", + "tokenized_community_burned": "Verbrannt", "token_comment_holders_only": "Kommentare sind nur für Token-Inhaber verfügbar.", "tokenized_community_not_available_description": "Die Tokenerstellung ist für zuvor erstellte Beiträge nicht verfügbar.", "tokenized_community_not_available_title": "Fehler bei tokenisiertem Inhalt", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f23ccfb21f..daa65f4699 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -918,6 +918,7 @@ "tokenized_community_comments_empty": "Be the first to join the conversation", "tokenized_community_comments_tab": "Comments", "tokenized_community_holders_tab": "Holders", + "tokenized_community_burned": "Burned", "token_comment_holders_only": "Comments are available only to token holders", "tokenized_community_not_available_description": "Token creation is not available for previously created posts.", "tokenized_community_not_available_title": "Tokenized content error", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5beee78851..29b34aff20 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -920,6 +920,7 @@ "tokenized_community_creator_token_live_title": "¡El token del creador está EN VIVO!", "tokenized_community_creator_token_live_subtitle": "Felicidades, tu token de creador ya está en vivo y disponible para que todos lo intercambien", "tokenized_community_holders_tab": "Tenedores", + "tokenized_community_burned": "Quemado", "token_comment_holders_only": "Los comentarios están disponibles solo para los tenedores de tokens.", "tokenized_community_not_available_description": "La creación de tokens no está disponible para publicaciones creadas previamente.", "tokenized_community_not_available_title": "Error de contenido tokenizado", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 733db9a72e..9c2820a549 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -920,6 +920,7 @@ "tokenized_community_creator_token_live_title": "Le token du créateur est EN DIRECT !", "tokenized_community_creator_token_live_subtitle": "Félicitations, votre token de créateur est maintenant en direct et disponible pour que tout le monde puisse l'échanger", "tokenized_community_holders_tab": "Détenteurs", + "tokenized_community_burned": "Brûlé", "token_comment_holders_only": "Les commentaires sont disponibles uniquement pour les détenteurs de tokens.", "tokenized_community_not_available_description": "La création de tokens n'est pas disponible pour les publications déjà créées.", "tokenized_community_not_available_title": "Erreur de contenu tokenisé", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 38528b9ee8..77c05e597e 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -920,6 +920,7 @@ "tokenized_community_creator_token_live_title": "Il token del creatore è LIVE!", "tokenized_community_creator_token_live_subtitle": "Congratulazioni, il tuo token creatore è ora attivo e disponibile per tutti per scambiarlo", "tokenized_community_holders_tab": "Detentori", + "tokenized_community_burned": "Bruciato", "token_comment_holders_only": "I commenti sono disponibili solo per i detentori di token.", "tokenized_community_not_available_description": "La creazione di token non è disponibile per i post creati in precedenza.", "tokenized_community_not_available_title": "Errore di contenuto tokenizzato", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index a61df767a7..f953799a1a 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -920,6 +920,7 @@ "tokenized_community_creator_token_live_title": "Token twórcy jest NA ŻYWO!", "tokenized_community_creator_token_live_subtitle": "Gratulacje, Twój token twórcy jest teraz aktywny i dostępny dla wszystkich do handlu", "tokenized_community_holders_tab": "Posiadacze", + "tokenized_community_burned": "Spalone", "token_comment_holders_only": "Komentarze są dostępne tylko dla posiadaczy tokenów.", "tokenized_community_not_available_description": "Tworzenie tokenów nie jest dostępne dla wcześniej utworzonych postów.", "tokenized_community_not_available_title": "Błąd tokenizowanej treści", diff --git a/lib/l10n/app_ro.arb b/lib/l10n/app_ro.arb index 00eaf66037..8c4538de19 100644 --- a/lib/l10n/app_ro.arb +++ b/lib/l10n/app_ro.arb @@ -920,6 +920,7 @@ "tokenized_community_creator_token_live_title": "Tokenul de creator este LIVE!", "tokenized_community_creator_token_live_subtitle": "Felicitări, tokenul tău de creator este acum activ și disponibil pentru toată lumea să îl tranzacționeze", "tokenized_community_holders_tab": "Deținători", + "tokenized_community_burned": "Arse", "token_comment_holders_only": "Comentariile sunt disponibile doar pentru deținătorii de tokenuri.", "tokenized_community_not_available_description": "Crearea de tokenuri nu este disponibilă pentru postările create anterior.", "tokenized_community_not_available_title": "Eroare de conținut tokenizat", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index e3ab61dfd8..71690feda3 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -920,6 +920,7 @@ "tokenized_community_creator_token_live_title": "Токен создателя АКТИВЕН!", "tokenized_community_creator_token_live_subtitle": "Поздравляем, ваш токен создателя теперь активен и доступен всем для торговли", "tokenized_community_holders_tab": "Держатели", + "tokenized_community_burned": "Сожжено", "token_comment_holders_only": "Комментарии доступны только держателям токенов.", "tokenized_community_not_available_description": "Создание токенов недоступно для ранее созданных публикаций.", "tokenized_community_not_available_title": "Ошибка токенизированного контента", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index d3f5dfc169..398a286dab 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -920,6 +920,7 @@ "tokenized_community_creator_token_live_title": "İçerik üreticisi tokeni YAYINDA!", "tokenized_community_creator_token_live_subtitle": "Tebrikler, içerik üreticisi tokeniniz artık yayında ve herkesin işlem yapması için mevcut", "tokenized_community_holders_tab": "Sahipler", + "tokenized_community_burned": "Yakıldı", "token_comment_holders_only": "Yorumlar yalnızca token sahipleri için kullanılabilir.", "tokenized_community_not_available_description": "Önceden oluşturulmuş gönderiler için token oluşturma kullanılamaz.", "tokenized_community_not_available_title": "Tokenlaştırılmış içerik hatası", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2e74a2a6d6..bc9638fdef 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -920,6 +920,7 @@ "tokenized_community_creator_token_live_title": "创作者代币已上线!", "tokenized_community_creator_token_live_subtitle": "恭喜,您的创作者代币现已上线,所有人都可以交易", "tokenized_community_holders_tab": "持有者", + "tokenized_community_burned": "已销毁", "token_comment_holders_only": "评论仅对代币持有者开放。", "tokenized_community_not_available_description": "已创建的帖子无法创建代币。", "tokenized_community_not_available_title": "代币化内容错误", diff --git a/packages/ion_token_analytics/lib/src/community_tokens/top_holders/models/top_holder.f.dart b/packages/ion_token_analytics/lib/src/community_tokens/top_holders/models/top_holder.f.dart index 0960295f64..1c10a90b15 100644 --- a/packages/ion_token_analytics/lib/src/community_tokens/top_holders/models/top_holder.f.dart +++ b/packages/ion_token_analytics/lib/src/community_tokens/top_holders/models/top_holder.f.dart @@ -35,6 +35,9 @@ extension TopHolderPatchExtension on TopHolderPatch { } } +/// Burn address for tokenized communities +const String _topHolderBurnAddress = '0x0000000000000000000000000000000000696f6e'; + extension TopHolderExtension on TopHolder { TopHolder merge(TopHolderPatch patch) { final orgJson = toJson(); @@ -44,4 +47,17 @@ extension TopHolderExtension on TopHolder { return TopHolder.fromJson(mergedJson); } + + /// Returns true if this holder is a burned holder (tokens sent to burn address) + bool get isBurning { + final holderAddress = position.holder?.addresses?.ionConnect; + return holderAddress != null && + holderAddress.toLowerCase() == _topHolderBurnAddress.toLowerCase(); + } + + /// Returns true if this holder is the bounding curve contract + bool isBoundingCurve(String address) { + final holderAddress = position.holder?.addresses?.ionConnect; + return holderAddress != null && holderAddress.toLowerCase() == address.toLowerCase(); + } }