diff --git a/assets/svg/iconoir_coins-swap.svg b/assets/svg/iconoir_coins-swap.svg
deleted file mode 100644
index 0479483158..0000000000
--- a/assets/svg/iconoir_coins-swap.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/lib/app/features/feed/providers/user_holdings_provider.r.dart b/lib/app/features/feed/providers/user_holdings_tab_provider.r.dart
similarity index 63%
rename from lib/app/features/feed/providers/user_holdings_provider.r.dart
rename to lib/app/features/feed/providers/user_holdings_tab_provider.r.dart
index 231e0b2bd0..f88e4a6227 100644
--- a/lib/app/features/feed/providers/user_holdings_provider.r.dart
+++ b/lib/app/features/feed/providers/user_holdings_tab_provider.r.dart
@@ -1,17 +1,15 @@
// SPDX-License-Identifier: ice License 1.0
-import 'package:ion/app/features/feed/data/models/entities/generic_repost.f.dart';
import 'package:ion/app/features/feed/providers/user_posts_data_source_provider.r.dart';
import 'package:ion/app/features/ion_connect/model/ion_connect_entity.dart';
import 'package:ion/app/features/ion_connect/providers/entities_paged_data_provider.m.dart';
-import 'package:ion/app/features/tokenized_communities/models/entities/community_token_action.f.dart';
-import 'package:ion/app/features/tokenized_communities/models/entities/community_token_definition.f.dart';
+import 'package:ion/app/features/tokenized_communities/utils/ion_connect_entity_extension.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
-part 'user_holdings_provider.r.g.dart';
+part 'user_holdings_tab_provider.r.g.dart';
@riverpod
-class UserHoldings extends _$UserHoldings with DelegatedPagedNotifier {
+class UserHoldingsTab extends _$UserHoldingsTab with DelegatedPagedNotifier {
@override
({Iterable? items, bool hasMore}) build(String pubkey) {
final dataSources = ref.watch(userPostsDataSourceProvider(pubkey));
@@ -30,7 +28,7 @@ class UserHoldings extends _$UserHoldings with DelegatedPagedNotifier {
}
// Filter to include only tokenized community entities
- final holdingsItems = allItems.where(_isTokenizedCommunityEntity);
+ final holdingsItems = allItems.where((entity) => entity.isTokenizedCommunityEntity);
// If filtered items are empty but we've loaded data, show empty state
// (set hasMore to false to prevent infinite loading)
@@ -47,12 +45,4 @@ class UserHoldings extends _$UserHoldings with DelegatedPagedNotifier {
}
return ref.read(entitiesPagedDataProvider(dataSources).notifier);
}
-
- bool _isTokenizedCommunityEntity(IonConnectEntity entity) {
- return entity is CommunityTokenActionEntity ||
- entity is CommunityTokenDefinitionEntity ||
- (entity is GenericRepostEntity &&
- (entity.data.kind == GenericRepostEntity.communityTokenDefinitionRepostKind ||
- entity.data.kind == GenericRepostEntity.communityTokenActionRepostKind));
- }
}
diff --git a/lib/app/features/feed/providers/user_posts_provider.r.dart b/lib/app/features/feed/providers/user_posts_provider.r.dart
index d06d45fd56..abca826933 100644
--- a/lib/app/features/feed/providers/user_posts_provider.r.dart
+++ b/lib/app/features/feed/providers/user_posts_provider.r.dart
@@ -12,8 +12,7 @@ import 'package:ion/app/features/feed/providers/repost_notifier.r.dart';
import 'package:ion/app/features/feed/providers/user_posts_data_source_provider.r.dart';
import 'package:ion/app/features/ion_connect/model/ion_connect_entity.dart';
import 'package:ion/app/features/ion_connect/providers/entities_paged_data_provider.m.dart';
-import 'package:ion/app/features/tokenized_communities/models/entities/community_token_action.f.dart';
-import 'package:ion/app/features/tokenized_communities/models/entities/community_token_definition.f.dart';
+import 'package:ion/app/features/tokenized_communities/utils/ion_connect_entity_extension.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'user_posts_provider.r.g.dart';
@@ -47,7 +46,7 @@ class UserPosts extends _$UserPosts with DelegatedPagedNotifier {
}
// Filter to exclude tokenized community entities (those go to Holdings tab)
- final nonTokenizedItems = allItems.where((entity) => !_isTokenizedCommunityEntity(entity));
+ final nonTokenizedItems = allItems.where((entity) => !entity.isTokenizedCommunityEntity);
// If filtered items are empty but we've loaded data, show empty state
// (set hasMore to false to prevent infinite loading)
@@ -71,7 +70,7 @@ class UserPosts extends _$UserPosts with DelegatedPagedNotifier {
}
// Exclude tokenized community entities (those go to Holdings tab)
- if (_isTokenizedCommunityEntity(entity)) {
+ if (entity.isTokenizedCommunityEntity) {
return false;
}
@@ -85,12 +84,4 @@ class UserPosts extends _$UserPosts with DelegatedPagedNotifier {
entity is RepostEntity ||
entity is ArticleEntity;
}
-
- bool _isTokenizedCommunityEntity(IonConnectEntity entity) {
- return entity is CommunityTokenActionEntity ||
- entity is CommunityTokenDefinitionEntity ||
- (entity is GenericRepostEntity &&
- (entity.data.kind == GenericRepostEntity.communityTokenDefinitionRepostKind ||
- entity.data.kind == GenericRepostEntity.communityTokenActionRepostKind));
- }
}
diff --git a/lib/app/features/tokenized_communities/utils/ion_connect_entity_extension.dart b/lib/app/features/tokenized_communities/utils/ion_connect_entity_extension.dart
new file mode 100644
index 0000000000..ac82a8a046
--- /dev/null
+++ b/lib/app/features/tokenized_communities/utils/ion_connect_entity_extension.dart
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: ice License 1.0
+
+import 'package:ion/app/features/feed/data/models/entities/generic_repost.f.dart';
+import 'package:ion/app/features/ion_connect/model/ion_connect_entity.dart';
+import 'package:ion/app/features/tokenized_communities/models/entities/community_token_action.f.dart';
+import 'package:ion/app/features/tokenized_communities/models/entities/community_token_definition.f.dart';
+
+extension IsTokenizedCommunityEntityExtension on IonConnectEntity {
+ bool get isTokenizedCommunityEntity {
+ return this is CommunityTokenActionEntity ||
+ this is CommunityTokenDefinitionEntity ||
+ (this is GenericRepostEntity &&
+ ((this as GenericRepostEntity).data.kind ==
+ GenericRepostEntity.communityTokenDefinitionRepostKind ||
+ (this as GenericRepostEntity).data.kind ==
+ GenericRepostEntity.communityTokenActionRepostKind));
+ }
+}
diff --git a/lib/app/features/user/pages/profile_page/components/tabs/activity_badge.dart b/lib/app/features/user/pages/profile_page/components/tabs/activity_badge.dart
deleted file mode 100644
index aa26cdce50..0000000000
--- a/lib/app/features/user/pages/profile_page/components/tabs/activity_badge.dart
+++ /dev/null
@@ -1,29 +0,0 @@
-// SPDX-License-Identifier: ice License 1.0
-
-import 'package:flutter/material.dart';
-import 'package:ion/app/extensions/extensions.dart';
-import 'package:ion/generated/assets.gen.dart';
-
-class ActivityBadge extends StatelessWidget {
- const ActivityBadge({super.key});
-
- @override
- Widget build(BuildContext context) {
- return Padding(
- padding: EdgeInsetsDirectional.only(start: 16.s, top: 8.s),
- child: Row(
- children: [
- Assets.svg.iconoirCoinsSwap.icon(size: 16.0.s),
- SizedBox(width: 6.0.s),
- Text(
- context.i18n.profile_activity,
- textAlign: TextAlign.center,
- style: context.theme.appTextThemes.subtitle3.copyWith(
- color: context.theme.appColors.onTertiaryBackground,
- ),
- ),
- ],
- ),
- );
- }
-}
diff --git a/lib/app/features/user/pages/profile_page/components/tabs/holdings_list.dart b/lib/app/features/user/pages/profile_page/components/tabs/holdings_list.dart
new file mode 100644
index 0000000000..5f1228a8d3
--- /dev/null
+++ b/lib/app/features/user/pages/profile_page/components/tabs/holdings_list.dart
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: ice License 1.0
+
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:ion/app/extensions/extensions.dart';
+import 'package:ion/app/features/user/pages/profile_page/components/tabs/user_holdings_list_item.dart';
+import 'package:ion/app/features/user/providers/user_holdings_provider.r.dart';
+import 'package:ion/app/features/user/providers/user_metadata_provider.r.dart';
+import 'package:ion/app/router/app_routes.gr.dart';
+import 'package:ion/generated/assets.gen.dart';
+
+class HoldingsList extends ConsumerWidget {
+ const HoldingsList({
+ required this.pubkey,
+ super.key,
+ });
+
+ final String pubkey;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final userMetadata = ref.watch(userMetadataProvider(pubkey)).valueOrNull;
+ final holderAddress = userMetadata?.toEventReference().toString();
+
+ if (holderAddress == null) {
+ return const SizedBox.shrink();
+ }
+
+ final holdingsAsync = ref.watch(userHoldingsProvider(holderAddress));
+
+ return holdingsAsync.when(
+ data: (holdingsData) {
+ final holdings = holdingsData.items;
+ final totalHoldingsCount = holdingsData.totalHoldings;
+
+ if (totalHoldingsCount == 0 || holdings.isEmpty) {
+ return const SizedBox.shrink();
+ }
+
+ return Container(
+ color: context.theme.appColors.secondaryBackground,
+ padding: EdgeInsets.symmetric(horizontal: 16.0.s, vertical: 12.0.s),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _Header(holdingsCount: totalHoldingsCount, holderAddress: holderAddress),
+ SizedBox(height: 14.0.s),
+ ...holdings.asMap().entries.map(
+ (entry) {
+ final index = entry.key;
+ final token = entry.value;
+ final isLast = index == holdings.length - 1;
+ return Padding(
+ padding: EdgeInsetsDirectional.only(bottom: isLast ? 0.0 : 14.0.s),
+ child: UserHoldingsListItem(token: token),
+ );
+ },
+ ),
+ ],
+ ),
+ );
+ },
+ loading: () => const SizedBox.shrink(),
+ error: (_, __) => const SizedBox.shrink(),
+ );
+ }
+}
+
+class _Header extends StatelessWidget {
+ const _Header({
+ required this.holdingsCount,
+ required this.holderAddress,
+ });
+
+ final int holdingsCount;
+ final String holderAddress;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = context.theme.appColors;
+ final texts = context.theme.appTextThemes;
+
+ return Row(
+ children: [
+ Assets.svg.iconTabsCoins.icon(
+ size: 18.0.s,
+ color: colors.onTertiaryBackground,
+ ),
+ SizedBox(width: 6.0.s),
+ Text(
+ context.i18n.profile_holdings_with_count(holdingsCount),
+ style: texts.subtitle3.copyWith(
+ color: colors.onTertiaryBackground,
+ ),
+ ),
+ const Spacer(),
+ GestureDetector(
+ onTap: () {
+ UserHoldingsRoute(holderAddress: holderAddress).push(context);
+ },
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: EdgeInsets.symmetric(horizontal: 6.0.s, vertical: 4.0.s),
+ child: Text(
+ context.i18n.core_view_all,
+ style: texts.caption.copyWith(
+ color: colors.primaryAccent,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/app/features/user/pages/profile_page/components/tabs/holdings_list_item.dart b/lib/app/features/user/pages/profile_page/components/tabs/holdings_list_item.dart
new file mode 100644
index 0000000000..abedcf108f
--- /dev/null
+++ b/lib/app/features/user/pages/profile_page/components/tabs/holdings_list_item.dart
@@ -0,0 +1,170 @@
+// SPDX-License-Identifier: ice License 1.0
+
+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/token_holder_position_provider.r.dart';
+import 'package:ion/app/features/tokenized_communities/utils/market_data_formatter.dart';
+import 'package:ion/app/features/tokenized_communities/views/pages/holders/components/holder_avatar.dart';
+import 'package:ion/app/utils/num.dart';
+import 'package:ion/generated/assets.gen.dart';
+import 'package:ion_token_analytics/ion_token_analytics.dart';
+
+class HoldingsListItem extends ConsumerWidget {
+ const HoldingsListItem({
+ required this.holder,
+ required this.tokenExternalAddress,
+ super.key,
+ });
+
+ final TopHolder holder;
+ final String tokenExternalAddress;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final holderAddress = holder.position.holder?.addresses?.ionConnect;
+ final positionAsync = holderAddress != null
+ ? ref.watch(
+ tokenHolderPositionProvider(
+ tokenExternalAddress,
+ holderAddress,
+ ),
+ )
+ : null;
+
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Expanded(
+ child: Row(
+ children: [
+ Stack(
+ clipBehavior: Clip.none,
+ children: [
+ HolderAvatar(
+ imageUrl: holder.position.holder?.avatar,
+ seed: holder.position.holder?.name ??
+ holder.position.holder?.addresses?.ionConnect,
+ ),
+ if (holder.position.holder?.addresses?.twitter != null)
+ PositionedDirectional(
+ start: 23.0.s,
+ top: 20.0.s,
+ child: Assets.svg.iconLoginXlogo.icon(size: 12.0.s),
+ ),
+ ],
+ ),
+ SizedBox(width: 8.0.s),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ _getDisplayName(context),
+ style: context.theme.appTextThemes.subtitle3.copyWith(
+ color: context.theme.appColors.primaryText,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ Row(
+ children: [
+ Assets.svg.iconTabsCoins.icon(
+ size: 14.15.s,
+ color: context.theme.appColors.quaternaryText,
+ ),
+ SizedBox(width: 4.0.s),
+ Text(
+ formatAmountCompactFromRaw(holder.position.amount),
+ style: context.theme.appTextThemes.caption.copyWith(
+ color: context.theme.appColors.quaternaryText,
+ ),
+ ),
+ SizedBox(width: 6.0.s),
+ Text(
+ '•',
+ style: context.theme.appTextThemes.caption.copyWith(
+ color: context.theme.appColors.quaternaryText,
+ ),
+ ),
+ SizedBox(width: 6.0.s),
+ if (positionAsync != null)
+ positionAsync.when(
+ data: (position) => position != null
+ ? _ProfitLossIndicator(position: position)
+ : const SizedBox.shrink(),
+ loading: () => const SizedBox.shrink(),
+ error: (_, __) => const SizedBox.shrink(),
+ )
+ else
+ const SizedBox.shrink(),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ Container(
+ padding: EdgeInsets.symmetric(horizontal: 6.0.s, vertical: 2.0.s),
+ decoration: BoxDecoration(
+ color: context.theme.appColors.primaryAccent,
+ borderRadius: BorderRadius.circular(12.0.s),
+ ),
+ child: Text(
+ '\$${MarketDataFormatter.formatCompactNumber(holder.position.amountUSD)}',
+ style: context.theme.appTextThemes.caption2.copyWith(
+ color: context.theme.appColors.onPrimaryAccent,
+ height: 16 / context.theme.appTextThemes.caption2.fontSize!,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ String _getDisplayName(BuildContext context) {
+ final holder = this.holder.position.holder;
+ final displayName = holder?.display ?? holder?.name ?? '';
+ final rank = this.holder.position.rank;
+ return '$displayName #$rank';
+ }
+}
+
+class _ProfitLossIndicator extends StatelessWidget {
+ const _ProfitLossIndicator({
+ required this.position,
+ });
+
+ final Position position;
+
+ @override
+ Widget build(BuildContext context) {
+ final isProfit = position.pnl >= 0;
+ final profitColor = isProfit
+ ? const Color(0xFF35D487) // success green from design
+ : const Color(0xFFFF396E); // loss red from design
+
+ final pnlSign = getNumericSign(position.pnl);
+ final pnlAmount = MarketDataFormatter.formatCompactNumber(position.pnl.abs());
+
+ return Row(
+ children: [
+ Assets.svg.iconCreatecoinProfit.icon(
+ size: 14.15.s,
+ color: profitColor,
+ ),
+ SizedBox(width: 4.0.s),
+ Text(
+ '$pnlSign\$$pnlAmount',
+ style: context.theme.appTextThemes.caption.copyWith(
+ color: profitColor,
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/app/features/user/pages/profile_page/components/tabs/tab_entities_list.dart b/lib/app/features/user/pages/profile_page/components/tabs/tab_entities_list.dart
index 38e04646b7..73c9395134 100644
--- a/lib/app/features/user/pages/profile_page/components/tabs/tab_entities_list.dart
+++ b/lib/app/features/user/pages/profile_page/components/tabs/tab_entities_list.dart
@@ -11,15 +11,15 @@ import 'package:ion/app/features/auth/providers/auth_provider.m.dart';
import 'package:ion/app/features/components/entities_list/entities_list.dart';
import 'package:ion/app/features/components/entities_list/entities_list_skeleton.dart';
import 'package:ion/app/features/components/entities_list/entity_list_item.f.dart';
-import 'package:ion/app/features/feed/providers/user_holdings_provider.r.dart';
+import 'package:ion/app/features/feed/providers/user_holdings_tab_provider.r.dart';
import 'package:ion/app/features/feed/providers/user_posts_provider.r.dart';
import 'package:ion/app/features/ion_connect/model/ion_connect_entity.dart';
import 'package:ion/app/features/ion_connect/providers/entities_paged_data_provider.m.dart';
import 'package:ion/app/features/tokenized_communities/providers/token_market_info_provider.r.dart';
import 'package:ion/app/features/tokenized_communities/views/components/your_position_card.dart';
import 'package:ion/app/features/user/model/tab_entity_type.dart';
-import 'package:ion/app/features/user/pages/profile_page/components/tabs/activity_badge.dart';
import 'package:ion/app/features/user/pages/profile_page/components/tabs/empty_state.dart';
+import 'package:ion/app/features/user/pages/profile_page/components/tabs/holdings_list.dart';
import 'package:ion/app/features/user/providers/tab_data_source_provider.r.dart';
import 'package:ion/app/features/user/providers/user_metadata_provider.r.dart';
import 'package:ion/app/features/user_block/providers/block_list_notifier.r.dart';
@@ -86,7 +86,7 @@ class TabEntitiesList extends HookConsumerWidget {
if (type == TabEntityType.posts) {
await ref.read(userPostsProvider(pubkey).notifier).fetchEntities();
} else if (type == TabEntityType.holdings) {
- await ref.read(userHoldingsProvider(pubkey).notifier).fetchEntities();
+ await ref.read(userHoldingsTabProvider(pubkey).notifier).fetchEntities();
} else {
final dataSource = ref.read(tabDataSourceProvider(type: type, pubkey: pubkey));
if (dataSource != null) {
@@ -101,15 +101,12 @@ class TabEntitiesList extends HookConsumerWidget {
child: Column(
children: [
SectionSeparator(height: 4.s),
- YourPositionCard(
- token: token,
- ),
+ YourPositionCard(token: token),
SectionSeparator(height: 8.s),
],
),
),
- if (type == TabEntityType.holdings && entities.isNotEmpty)
- const SliverToBoxAdapter(child: ActivityBadge()),
+ if (type == TabEntityType.holdings) SliverToBoxAdapter(child: HoldingsList(pubkey: pubkey)),
if (entities.isEmpty && tabData.hasMore)
const EntitiesListSkeleton()
else if (entities.isEmpty || isBlockedOrBlockedBy)
@@ -151,7 +148,7 @@ class TabEntitiesList extends HookConsumerWidget {
if (type == TabEntityType.posts) {
ref.invalidate(userPostsProvider(pubkey));
} else if (type == TabEntityType.holdings) {
- ref.invalidate(userHoldingsProvider(pubkey));
+ ref.invalidate(userHoldingsTabProvider(pubkey));
} else {
final dataSource = ref.read(tabDataSourceProvider(type: type, pubkey: pubkey));
if (dataSource != null) {
diff --git a/lib/app/features/user/pages/profile_page/components/tabs/user_holdings_list_item.dart b/lib/app/features/user/pages/profile_page/components/tabs/user_holdings_list_item.dart
new file mode 100644
index 0000000000..35f587b9cc
--- /dev/null
+++ b/lib/app/features/user/pages/profile_page/components/tabs/user_holdings_list_item.dart
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: ice License 1.0
+
+import 'package:flutter/material.dart';
+import 'package:ion/app/components/avatar/avatar.dart';
+import 'package:ion/app/extensions/extensions.dart';
+import 'package:ion/app/features/tokenized_communities/utils/market_data_formatter.dart';
+import 'package:ion/app/utils/num.dart';
+import 'package:ion/generated/assets.gen.dart';
+import 'package:ion_token_analytics/ion_token_analytics.dart';
+
+class UserHoldingsListItem extends StatelessWidget {
+ const UserHoldingsListItem({
+ required this.token,
+ super.key,
+ });
+
+ final CommunityToken token;
+
+ @override
+ Widget build(BuildContext context) {
+ final position = token.marketData.position;
+ if (position == null) {
+ return const SizedBox.shrink();
+ }
+
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Expanded(
+ child: Row(
+ children: [
+ Stack(
+ clipBehavior: Clip.none,
+ children: [
+ Avatar(
+ size: 30.0.s,
+ imageUrl: token.imageUrl,
+ borderRadius: BorderRadius.circular(10.0.s),
+ ),
+ if (token.addresses.twitter != null)
+ PositionedDirectional(
+ start: 23.0.s,
+ top: 20.0.s,
+ child: _TwitterBadge(),
+ ),
+ ],
+ ),
+ SizedBox(width: 8.0.s),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisAlignment: MainAxisAlignment.center,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ '${token.title} #${position.rank}',
+ style: context.theme.appTextThemes.subtitle3.copyWith(
+ color: context.theme.appColors.primaryText,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ Row(
+ children: [
+ Assets.svg.iconTabsCoins.icon(
+ size: 14.15.s,
+ color: context.theme.appColors.quaternaryText,
+ ),
+ SizedBox(width: 4.0.s),
+ Text(
+ formatAmountCompactFromRaw(position.amount),
+ style: context.theme.appTextThemes.caption.copyWith(
+ color: context.theme.appColors.quaternaryText,
+ ),
+ ),
+ SizedBox(width: 6.0.s),
+ Text(
+ '•',
+ style: context.theme.appTextThemes.caption.copyWith(
+ color: context.theme.appColors.quaternaryText,
+ ),
+ ),
+ SizedBox(width: 6.0.s),
+ _ProfitLossIndicator(position: position),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ Container(
+ padding: EdgeInsets.symmetric(horizontal: 6.0.s, vertical: 2.0.s),
+ decoration: BoxDecoration(
+ color: context.theme.appColors.primaryAccent,
+ borderRadius: BorderRadius.circular(12.0.s),
+ ),
+ child: Text(
+ '\$${MarketDataFormatter.formatCompactNumber(position.amountUSD)}',
+ style: context.theme.appTextThemes.caption2.copyWith(
+ color: context.theme.appColors.onPrimaryAccent,
+ height: 16 / context.theme.appTextThemes.caption2.fontSize!,
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+}
+
+class _TwitterBadge extends StatelessWidget {
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: 13.0.s,
+ height: 13.0.s,
+ decoration: BoxDecoration(
+ color: const Color(0xFF1D1E20),
+ borderRadius: BorderRadius.circular(4.0.s),
+ ),
+ child: Center(
+ child: Assets.svg.iconLoginXlogo.icon(
+ size: 8.0.s,
+ color: Colors.white,
+ ),
+ ),
+ );
+ }
+}
+
+class _ProfitLossIndicator extends StatelessWidget {
+ const _ProfitLossIndicator({
+ required this.position,
+ });
+
+ final Position position;
+
+ @override
+ Widget build(BuildContext context) {
+ final isProfit = position.pnl >= 0;
+ final profitColor =
+ isProfit ? context.theme.appColors.success : context.theme.appColors.raspberry;
+
+ final pnlSign = getNumericSign(position.pnl);
+ final pnlAmount = MarketDataFormatter.formatCompactNumber(position.pnl.abs());
+
+ return Row(
+ children: [
+ Assets.svg.iconCreatecoinProfit.icon(
+ size: 14.15.s,
+ color: profitColor,
+ ),
+ SizedBox(width: 4.0.s),
+ Text(
+ '$pnlSign\$$pnlAmount',
+ style: context.theme.appTextThemes.caption.copyWith(
+ color: profitColor,
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/app/features/user/pages/profile_page/pages/user_holdings_page.dart b/lib/app/features/user/pages/profile_page/pages/user_holdings_page.dart
new file mode 100644
index 0000000000..7fbf4153d0
--- /dev/null
+++ b/lib/app/features/user/pages/profile_page/pages/user_holdings_page.dart
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: ice License 1.0
+
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:ion/app/components/progress_bar/ion_loading_indicator.dart';
+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/views/pages/holders/components/top_holders/components/top_holders_skeleton.dart';
+import 'package:ion/app/features/user/pages/profile_page/components/tabs/user_holdings_list_item.dart';
+import 'package:ion/app/features/user/providers/user_holdings_provider.r.dart';
+import 'package:ion/app/router/components/navigation_app_bar/navigation_app_bar.dart';
+
+class UserHoldingsPage extends HookConsumerWidget {
+ const UserHoldingsPage({required this.holderAddress, super.key});
+
+ final String holderAddress;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final scrollController = useScrollController();
+ final isLoadingMore = useState(false);
+ final hasMore = useState(true);
+ final offset = useState(0);
+
+ final holdingsAsync = ref.watch(userHoldingsProvider(holderAddress, limit: 20));
+ final holdings = holdingsAsync.valueOrNull?.items ?? const [];
+
+ return Scaffold(
+ appBar: NavigationAppBar.screen(
+ title: Text(context.i18n.profile_holdings, style: context.theme.appTextThemes.subtitle2),
+ ),
+ body: Column(
+ children: [
+ const SimpleSeparator(),
+ Expanded(
+ child: LoadMoreBuilder(
+ showIndicator: false,
+ slivers: [
+ if (holdingsAsync.isLoading && holdings.isEmpty)
+ SliverToBoxAdapter(
+ child: Padding(
+ padding: EdgeInsetsDirectional.symmetric(horizontal: 16.s, vertical: 12.s),
+ child: TopHoldersSkeleton(count: 20, seperatorHeight: 14.s),
+ ),
+ )
+ else
+ SliverList.builder(
+ itemCount: holdings.length,
+ itemBuilder: (context, index) {
+ final topPadding = index == 0 ? 12.s : 7.s;
+ final bottomPadding = 7.s;
+
+ return Padding(
+ padding: EdgeInsetsDirectional.only(
+ top: topPadding,
+ bottom: bottomPadding,
+ start: 16.s,
+ end: 16.s,
+ ),
+ child: UserHoldingsListItem(token: holdings[index]),
+ );
+ },
+ ),
+ if (isLoadingMore.value)
+ SliverToBoxAdapter(
+ child: Center(
+ child: Padding(
+ padding: EdgeInsetsDirectional.all(10.0.s),
+ child: const IONLoadingIndicatorThemed(),
+ ),
+ ),
+ ),
+ ],
+ onLoadMore: () async {
+ if (holdingsAsync.isLoading) return;
+ if (isLoadingMore.value || !hasMore.value) return;
+
+ isLoadingMore.value = true;
+ try {
+ final newOffset = offset.value + 20;
+ final result = await ref.read(
+ userHoldingsProvider(holderAddress, limit: 20, offset: newOffset).future,
+ );
+ offset.value = newOffset;
+ hasMore.value = result.hasMore;
+ } finally {
+ isLoadingMore.value = false;
+ }
+ },
+ hasMore: hasMore.value,
+ builder: (context, slivers) {
+ return PullToRefreshBuilder(
+ slivers: slivers,
+ onRefresh: () async {
+ hasMore.value = true;
+ offset.value = 0;
+ ref.invalidate(userHoldingsProvider(holderAddress, limit: 20));
+ },
+ builder: (context, slivers) => CustomScrollView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ controller: scrollController,
+ slivers: slivers,
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/app/features/user/providers/force_account_security_notifier.r.dart b/lib/app/features/user/providers/force_account_security_notifier.r.dart
index 447571b149..5ab249e90d 100644
--- a/lib/app/features/user/providers/force_account_security_notifier.r.dart
+++ b/lib/app/features/user/providers/force_account_security_notifier.r.dart
@@ -13,12 +13,10 @@ import 'package:ion/app/features/core/providers/splash_provider.r.dart';
import 'package:ion/app/features/protect_account/secure_account/providers/recovery_credentials_enabled_notifier.r.dart';
import 'package:ion/app/features/protect_account/secure_account/providers/security_account_provider.r.dart';
import 'package:ion/app/features/protect_account/secure_account/providers/user_details_provider.r.dart';
-import 'package:ion/app/features/protect_account/secure_account/views/pages/secure_account_modal.dart';
import 'package:ion/app/features/user/model/user_metadata.f.dart';
import 'package:ion/app/features/user/providers/user_metadata_provider.r.dart';
import 'package:ion/app/router/app_routes.gr.dart';
import 'package:ion/app/router/providers/route_location_provider.r.dart';
-import 'package:ion/app/services/ui_event_queue/ui_event_queue_notifier.r.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'force_account_security_notifier.r.g.dart';
@@ -112,7 +110,7 @@ ForceAccountSecurityService forceAccountSecurityService(Ref ref) {
EnvVariable.ENFORCE_ACCOUNT_SECURITY_DELAY_IN_MINUTES,
),
emitDialog: () {
- ref.read(uiEventQueueNotifierProvider.notifier).emit(const SecureAccountDialogEvent());
+ // ref.read(uiEventQueueNotifierProvider.notifier).emit(const SecureAccountDialogEvent());
},
);
diff --git a/lib/app/features/user/providers/tab_data_source_provider.r.dart b/lib/app/features/user/providers/tab_data_source_provider.r.dart
index a4fcdba9e8..4aba341017 100644
--- a/lib/app/features/user/providers/tab_data_source_provider.r.dart
+++ b/lib/app/features/user/providers/tab_data_source_provider.r.dart
@@ -2,7 +2,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:ion/app/features/feed/providers/user_articles_data_source_provider.r.dart';
-import 'package:ion/app/features/feed/providers/user_holdings_provider.r.dart';
+import 'package:ion/app/features/feed/providers/user_holdings_tab_provider.r.dart';
import 'package:ion/app/features/feed/providers/user_posts_data_source_provider.r.dart';
import 'package:ion/app/features/feed/providers/user_posts_provider.r.dart';
import 'package:ion/app/features/feed/providers/user_replies_data_source_provider.r.dart';
@@ -44,7 +44,7 @@ List? tabDataSource(
case TabEntityType.posts:
return ref.watch(userPostsProvider(pubkey));
case TabEntityType.holdings:
- return ref.watch(userHoldingsProvider(pubkey));
+ return ref.watch(userHoldingsTabProvider(pubkey));
case TabEntityType.articles:
case TabEntityType.replies:
case TabEntityType.videos:
diff --git a/lib/app/features/user/providers/user_holdings_provider.r.dart b/lib/app/features/user/providers/user_holdings_provider.r.dart
new file mode 100644
index 0000000000..641540c0af
--- /dev/null
+++ b/lib/app/features/user/providers/user_holdings_provider.r.dart
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: ice License 1.0
+
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:ion/app/services/ion_token_analytics/ion_token_analytics_client_provider.r.dart';
+import 'package:ion_token_analytics/ion_token_analytics.dart';
+import 'package:riverpod_annotation/riverpod_annotation.dart';
+
+part 'user_holdings_provider.r.g.dart';
+
+@riverpod
+Future userHoldings(
+ Ref ref,
+ String holder, {
+ int limit = 5,
+ int offset = 0,
+}) async {
+ final client = await ref.watch(ionTokenAnalyticsClientProvider.future);
+ return client.communityTokens.getUserHoldings(
+ holder: holder,
+ limit: limit,
+ offset: offset,
+ );
+}
diff --git a/lib/app/router/app_routes.gr.dart b/lib/app/router/app_routes.gr.dart
index c22b47d64f..aa71e997c2 100644
--- a/lib/app/router/app_routes.gr.dart
+++ b/lib/app/router/app_routes.gr.dart
@@ -136,6 +136,7 @@ import 'package:ion/app/features/user/pages/profile_edit_page/profile_edit_page.
import 'package:ion/app/features/user/pages/profile_page/pages/follow_list_modal/follow_list_modal.dart';
import 'package:ion/app/features/user/pages/profile_page/pages/payment_selection_modal/payment_selection_modal.dart';
import 'package:ion/app/features/user/pages/profile_page/pages/request_coins_form_modal/request_coins_form_modal.dart';
+import 'package:ion/app/features/user/pages/profile_page/pages/user_holdings_page.dart';
import 'package:ion/app/features/user/pages/profile_page/profile_page.dart';
import 'package:ion/app/features/user/pages/profile_page/self_profile_page.dart';
import 'package:ion/app/features/user/pages/switch_account_modal/switch_account_modal.dart';
diff --git a/lib/app/router/profile_routes.dart b/lib/app/router/profile_routes.dart
index 78988f2582..0991297998 100644
--- a/lib/app/router/profile_routes.dart
+++ b/lib/app/router/profile_routes.dart
@@ -16,6 +16,7 @@ class ProfileRoutes {
),
TypedGoRoute(path: 'creator-tokens-fullstack'),
TypedGoRoute(path: 'holders-fullstack'),
+ TypedGoRoute(path: 'user-holdings-fullstack'),
TypedGoRoute(path: 'trades-fullstack'),
TypedShellRoute(
routes: [
@@ -430,3 +431,12 @@ class TradesRoute extends BaseRouteData with _$TradesRoute {
final String externalAddress;
}
+
+class UserHoldingsRoute extends BaseRouteData with _$UserHoldingsRoute {
+ UserHoldingsRoute({required this.holderAddress})
+ : super(
+ child: UserHoldingsPage(holderAddress: holderAddress),
+ );
+
+ final String holderAddress;
+}
diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb
index da9339cff6..8f81b57e0e 100644
--- a/lib/l10n/app_ar.arb
+++ b/lib/l10n/app_ar.arb
@@ -757,6 +757,7 @@
"profile_holdings": "الممتلكات",
"profile_holdings_empty_list": "ليس لدى {username} أي ممتلكات بعد",
"profile_holdings_empty_list_current_user": "ليس لديك أي ممتلكات بعد",
+ "profile_holdings_with_count": "الممتلكات ({count})",
"profile_location": "الموقع",
"profile_log_out": "تسجيل الخروج {nickname}",
"profile_login_with_new_account": "تسجيل الدخول بحساب جديد",
@@ -918,6 +919,7 @@
"story_share_sender": "لقد شاركت هذه القصة",
"suggestions_empty_description": "اكتب لمزيد من الاختيارات من القائمة",
"suggestions_loading_description": "ابحث عن المستخدم الذي تريد ذكره",
+ "token_comment_holders_only": "التعليقات متاحة فقط لحاملي الرموز.",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -931,12 +933,11 @@
"tokenized_community_comments_empty": "كن أول من ينضم إلى المحادثة",
"tokenized_community_comments_tab": "التعليقات",
"tokenized_community_holders_tab": "الحائزون",
- "token_comment_holders_only": "التعليقات متاحة فقط لحاملي الرموز.",
"tokenized_community_not_available_description": "إنشاء الرموز غير متاح للمنشورات التي تم إنشاؤها مسبقًا.",
"tokenized_community_not_available_title": "خطأ في المحتوى المرمز",
+ "tokenized_community_token_content": "رمز المحتوى",
"tokenized_community_token_creator": "رمز المنشئ",
"tokenized_community_token_twitter": "رمز X",
- "tokenized_community_token_content": "رمز المحتوى",
"tokenized_community_trades_tab": "الصفقات",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "أضف رابط",
diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb
index 52c44ed9db..e86dadc9a7 100644
--- a/lib/l10n/app_bg.arb
+++ b/lib/l10n/app_bg.arb
@@ -745,6 +745,7 @@
"profile_holdings": "Държания",
"profile_holdings_empty_list": "{username} все още няма държания",
"profile_holdings_empty_list_current_user": "Все още нямате държания",
+ "profile_holdings_with_count": "Държания ({count})",
"profile_location": "Местоположение",
"profile_log_out": "Изход от {nickname}",
"profile_login_with_new_account": "Влезте с нов акаунт",
@@ -905,6 +906,7 @@
"story_share_sender": "Вие споделихте тази история",
"suggestions_empty_description": "Не успяхме да намерим нищо, което да съответства на вашето търсене",
"suggestions_loading_description": "Търсете потребителя, който искате да споменете",
+ "token_comment_holders_only": "Коментарите са достъпни само за притежатели на токени.",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -918,12 +920,11 @@
"tokenized_community_comments_empty": "Бъдете първият, който се присъединява към разговора",
"tokenized_community_comments_tab": "Коментари",
"tokenized_community_holders_tab": "Притежатели",
- "token_comment_holders_only": "Коментарите са достъпни само за притежатели на токени.",
"tokenized_community_not_available_description": "Създаването на токени не е налично за вече създадени публикации.",
"tokenized_community_not_available_title": "Грешка при токенизирано съдържание",
+ "tokenized_community_token_content": "Токен на съдържанието",
"tokenized_community_token_creator": "Токен на създателя",
"tokenized_community_token_twitter": "Токен на X",
- "tokenized_community_token_content": "Токен на съдържанието",
"tokenized_community_trades_tab": "Сделки",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "Добави връзка",
diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb
index 8b965c16cc..19cbad6b0a 100644
--- a/lib/l10n/app_de.arb
+++ b/lib/l10n/app_de.arb
@@ -745,6 +745,7 @@
"profile_holdings": "Bestände",
"profile_holdings_empty_list": "{username} hat noch keine Bestände",
"profile_holdings_empty_list_current_user": "Sie haben noch keine Bestände",
+ "profile_holdings_with_count": "Bestände ({count})",
"profile_location": "Standort",
"profile_log_out": "Als {nickname} abmelden",
"profile_login_with_new_account": "Mit neuem Konto anmelden",
@@ -905,6 +906,7 @@
"story_share_sender": "Du hast diese Story geteilt",
"suggestions_empty_description": "Wir konnten nichts finden, das deiner Suche entspricht.",
"suggestions_loading_description": "Suche nach dem Benutzer, den du erwähnen möchtest",
+ "token_comment_holders_only": "Kommentare sind nur für Token-Inhaber verfügbar.",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -918,12 +920,11 @@
"tokenized_community_comments_empty": "Sei der Erste, der sich an der Unterhaltung beteiligt",
"tokenized_community_comments_tab": "Kommentare",
"tokenized_community_holders_tab": "Inhaber",
- "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",
+ "tokenized_community_token_content": "Content-Token",
"tokenized_community_token_creator": "Creator-Token",
"tokenized_community_token_twitter": "X-Token",
- "tokenized_community_token_content": "Content-Token",
"tokenized_community_trades_tab": "Trades",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "Link hinzufügen",
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index b0dc0d2f69..f6ff9004b2 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -745,6 +745,7 @@
"profile_holdings": "Holdings",
"profile_holdings_empty_list": "{username} doesn't have any holdings yet",
"profile_holdings_empty_list_current_user": "You don't have any holdings yet",
+ "profile_holdings_with_count": "Holdings ({count})",
"profile_location": "Location",
"profile_log_out": "Log out {nickname}",
"profile_login_with_new_account": "Login with new account",
@@ -905,6 +906,7 @@
"story_share_sender": "You shared this story",
"suggestions_empty_description": "We couldn’t find anything matching your search",
"suggestions_loading_description": "Search for the user you want to mention",
+ "token_comment_holders_only": "Comments are available only to token holders",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -918,12 +920,11 @@
"tokenized_community_comments_empty": "Be the first to join the conversation",
"tokenized_community_comments_tab": "Comments",
"tokenized_community_holders_tab": "Holders",
- "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",
+ "tokenized_community_token_content": "Content token",
"tokenized_community_token_creator": "Creator token",
"tokenized_community_token_twitter": "X token",
- "tokenized_community_token_content": "Content token",
"tokenized_community_trades_tab": "Trades",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "Add a link",
diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb
index 0e7f299299..1bca967b12 100644
--- a/lib/l10n/app_es.arb
+++ b/lib/l10n/app_es.arb
@@ -745,6 +745,7 @@
"profile_holdings": "Tenencia",
"profile_holdings_empty_list": "{username} aún no tiene ninguna tenencia",
"profile_holdings_empty_list_current_user": "Aún no tienes ninguna tenencia",
+ "profile_holdings_with_count": "Tenencia ({count})",
"profile_location": "Ubicación",
"profile_log_out": "Cerrar sesión de {nickname}",
"profile_login_with_new_account": "Iniciar sesión con nueva cuenta",
@@ -905,6 +906,7 @@
"story_share_sender": "Compartiste esta historia",
"suggestions_empty_description": "No pudimos encontrar nada que coincida con tu búsqueda",
"suggestions_loading_description": "Busca el usuario que quieres mencionar",
+ "token_comment_holders_only": "Los comentarios están disponibles solo para los tenedores de tokens.",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -918,12 +920,11 @@
"tokenized_community_comments_empty": "Sé el primero en unirse a la conversación",
"tokenized_community_comments_tab": "Comentarios",
"tokenized_community_holders_tab": "Tenedores",
- "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",
+ "tokenized_community_token_content": "Token de contenido",
"tokenized_community_token_creator": "Token de creador",
"tokenized_community_token_twitter": "Token de X",
- "tokenized_community_token_content": "Token de contenido",
"tokenized_community_trades_tab": "Operaciones",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "Agregar un enlace",
diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb
index 1432c9d787..691ba1f199 100644
--- a/lib/l10n/app_fr.arb
+++ b/lib/l10n/app_fr.arb
@@ -745,6 +745,7 @@
"profile_holdings": "Portefeuille",
"profile_holdings_empty_list": "{username} n'a pas encore de portefeuille",
"profile_holdings_empty_list_current_user": "Vous n'avez pas encore de portefeuille",
+ "profile_holdings_with_count": "Portefeuille ({count})",
"profile_location": "Localisation",
"profile_log_out": "Déconnecter {nickname}",
"profile_login_with_new_account": "Se connecter avec un nouveau compte",
@@ -905,6 +906,7 @@
"story_share_sender": "Vous avez partagé cette histoire",
"suggestions_empty_description": "Nous n'avons rien trouvé correspondant à votre recherche",
"suggestions_loading_description": "Recherchez l'utilisateur que vous souhaitez mentionner",
+ "token_comment_holders_only": "Les commentaires sont disponibles uniquement pour les détenteurs de tokens.",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -918,12 +920,11 @@
"tokenized_community_comments_empty": "Soyez le premier à rejoindre la conversation",
"tokenized_community_comments_tab": "Commentaires",
"tokenized_community_holders_tab": "Détenteurs",
- "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é",
+ "tokenized_community_token_content": "Token de contenu",
"tokenized_community_token_creator": "Token de créateur",
"tokenized_community_token_twitter": "Token X",
- "tokenized_community_token_content": "Token de contenu",
"tokenized_community_trades_tab": "Transactions",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "Ajouter un lien",
diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb
index 88946161ee..e803a5eae8 100644
--- a/lib/l10n/app_it.arb
+++ b/lib/l10n/app_it.arb
@@ -745,6 +745,7 @@
"profile_holdings": "Possedimenti",
"profile_holdings_empty_list": "{username} non ha ancora possedimenti",
"profile_holdings_empty_list_current_user": "Non hai ancora possedimenti",
+ "profile_holdings_with_count": "Possedimenti ({count})",
"profile_location": "Posizione",
"profile_log_out": "Esci da {nickname}",
"profile_login_with_new_account": "Accedi con un nuovo account",
@@ -905,6 +906,7 @@
"story_share_sender": "Hai condiviso questa storia",
"suggestions_empty_description": "Non siamo riusciti a trovare nulla corrispondente alla tua ricerca",
"suggestions_loading_description": "Cerca l'utente che vuoi menzionare",
+ "token_comment_holders_only": "I commenti sono disponibili solo per i detentori di token.",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -918,12 +920,11 @@
"tokenized_community_comments_empty": "Sii il primo a unirti alla conversazione",
"tokenized_community_comments_tab": "Commenti",
"tokenized_community_holders_tab": "Detentori",
- "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",
+ "tokenized_community_token_content": "Token del contenuto",
"tokenized_community_token_creator": "Token del creatore",
"tokenized_community_token_twitter": "Token di X",
- "tokenized_community_token_content": "Token del contenuto",
"tokenized_community_trades_tab": "Operazioni",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "Aggiungi un link",
diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb
index 18e12e0951..4a60be92b9 100644
--- a/lib/l10n/app_pl.arb
+++ b/lib/l10n/app_pl.arb
@@ -745,6 +745,7 @@
"profile_holdings": "Zasoby",
"profile_holdings_empty_list": "{username} nie ma jeszcze żadnych zasobów",
"profile_holdings_empty_list_current_user": "Nie masz jeszcze żadnych zasobów",
+ "profile_holdings_with_count": "Zasoby ({count})",
"profile_location": "Lokalizacja",
"profile_log_out": "Wyloguj {nickname}",
"profile_login_with_new_account": "Zaloguj się nowym kontem",
@@ -905,6 +906,7 @@
"story_share_sender": "Udostępniłeś tę historię",
"suggestions_empty_description": "Nie znaleźliśmy niczego pasującego do Twojego wyszukiwania",
"suggestions_loading_description": "Szukaj użytkownika, którego chcesz wspomnieć",
+ "token_comment_holders_only": "Komentarze są dostępne tylko dla posiadaczy tokenów.",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -918,12 +920,11 @@
"tokenized_community_comments_empty": "Bądź pierwszym, który dołączy do rozmowy",
"tokenized_community_comments_tab": "Komentarze",
"tokenized_community_holders_tab": "Posiadacze",
- "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",
+ "tokenized_community_token_content": "Token treści",
"tokenized_community_token_creator": "Token twórcy",
"tokenized_community_token_twitter": "Token X",
- "tokenized_community_token_content": "Token treści",
"tokenized_community_trades_tab": "Transakcje",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "Dodaj link",
diff --git a/lib/l10n/app_ro.arb b/lib/l10n/app_ro.arb
index e9dbf60086..b5987a9da9 100644
--- a/lib/l10n/app_ro.arb
+++ b/lib/l10n/app_ro.arb
@@ -745,6 +745,7 @@
"profile_holdings": "Deținări",
"profile_holdings_empty_list": "{username} nu are încă deținări",
"profile_holdings_empty_list_current_user": "Nu aveți încă deținări",
+ "profile_holdings_with_count": "Deținări ({count})",
"profile_location": "Locație",
"profile_log_out": "Deconectează-te de la {nickname}",
"profile_login_with_new_account": "Conectează-te cu un cont nou",
@@ -905,6 +906,7 @@
"story_share_sender": "Ai distribuit această poveste",
"suggestions_empty_description": "Nu am găsit nimic care să se potrivească căutării tale",
"suggestions_loading_description": "Caută utilizatorul pe care vrei să-l menționezi",
+ "token_comment_holders_only": "Comentariile sunt disponibile doar pentru deținătorii de tokenuri.",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -918,12 +920,11 @@
"tokenized_community_comments_empty": "Fii primul care se alătură conversației",
"tokenized_community_comments_tab": "Comentarii",
"tokenized_community_holders_tab": "Deținători",
- "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",
+ "tokenized_community_token_content": "Token conținut",
"tokenized_community_token_creator": "Token creator",
"tokenized_community_token_twitter": "Token X",
- "tokenized_community_token_content": "Token conținut",
"tokenized_community_trades_tab": "Tranzacții",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "Adaugă un link",
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 96ad0e60e0..9a200613bd 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -745,6 +745,7 @@
"profile_holdings": "Холдинги",
"profile_holdings_empty_list": "У {username} пока нет холдингов",
"profile_holdings_empty_list_current_user": "У вас пока нет холдингов",
+ "profile_holdings_with_count": "Холдинги ({count})",
"profile_location": "Местоположение",
"profile_log_out": "Выйти из {nickname}",
"profile_login_with_new_account": "Войти с новым аккаунтом",
@@ -905,6 +906,7 @@
"story_share_sender": "Вы поделились этой историей",
"suggestions_empty_description": "Мы не смогли найти ничего, соответствующего вашему поиску",
"suggestions_loading_description": "Ищите пользователя, которого хотите упомянуть",
+ "token_comment_holders_only": "Комментарии доступны только держателям токенов.",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -918,12 +920,11 @@
"tokenized_community_comments_empty": "Станьте первым, кто присоединится к разговору",
"tokenized_community_comments_tab": "Комментарии",
"tokenized_community_holders_tab": "Держатели",
- "token_comment_holders_only": "Комментарии доступны только держателям токенов.",
"tokenized_community_not_available_description": "Создание токенов недоступно для ранее созданных публикаций.",
"tokenized_community_not_available_title": "Ошибка токенизированного контента",
+ "tokenized_community_token_content": "Токен контента",
"tokenized_community_token_creator": "Токен создателя",
"tokenized_community_token_twitter": "Токен X",
- "tokenized_community_token_content": "Токен контента",
"tokenized_community_trades_tab": "Сделки",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "Добавить ссылку",
diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb
index 74222db5f2..875d04b7aa 100644
--- a/lib/l10n/app_tr.arb
+++ b/lib/l10n/app_tr.arb
@@ -745,6 +745,7 @@
"profile_holdings": "Varlıklar",
"profile_holdings_empty_list": "{username} henüz varlığa sahip değil",
"profile_holdings_empty_list_current_user": "Henüz varlığa sahip değilsiniz",
+ "profile_holdings_with_count": "Varlıklar ({count})",
"profile_location": "Konum",
"profile_log_out": "{nickname} çıkış yap",
"profile_login_with_new_account": "Yeni hesapla giriş yap",
@@ -905,6 +906,7 @@
"story_share_sender": "Bu hikayeyi paylaştınız",
"suggestions_empty_description": "Aramanıza uyan hiçbir şey bulamadık",
"suggestions_loading_description": "Bahsetmek istediğiniz kullanıcıyı arayın",
+ "token_comment_holders_only": "Yorumlar yalnızca token sahipleri için kullanılabilir.",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -918,12 +920,11 @@
"tokenized_community_comments_empty": "Sohbete katılan ilk kişi olun",
"tokenized_community_comments_tab": "Yorumlar",
"tokenized_community_holders_tab": "Sahipler",
- "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ı",
+ "tokenized_community_token_content": "İçerik tokenı",
"tokenized_community_token_creator": "Oluşturucu tokenı",
"tokenized_community_token_twitter": "X tokenı",
- "tokenized_community_token_content": "İçerik tokenı",
"tokenized_community_trades_tab": "İşlemler",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "Bağlantı ekle",
diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb
index fe52890856..d300f60e05 100644
--- a/lib/l10n/app_zh.arb
+++ b/lib/l10n/app_zh.arb
@@ -745,6 +745,7 @@
"profile_holdings": "持仓",
"profile_holdings_empty_list": "{username} 还没有任何持仓",
"profile_holdings_empty_list_current_user": "您还没有任何持仓",
+ "profile_holdings_with_count": "持仓 ({count})",
"profile_location": "位置",
"profile_log_out": "登出 {nickname}",
"profile_login_with_new_account": "使用新账户登录",
@@ -905,6 +906,7 @@
"story_share_sender": "您分享了这个故事",
"suggestions_empty_description": "我们找不到任何与您的搜索匹配的内容",
"suggestions_loading_description": "搜索您想要提及的用户",
+ "token_comment_holders_only": "评论仅对代币持有者开放。",
"token_stats_holders_description": "Holders represent the number of unique wallets that currently own the token.\n\nA rising number of holders is often a strong indicator of growing adoption, community interest, and broader distribution. While a healthy spread of ownership can contribute to long-term stability, tracking holder evolution over time can reveal increasing confidence and momentum around the project.",
"token_stats_holders_title": "Holders",
"token_stats_market_cap_description": "The market capitalization in the script is the total market value of the cryptocurrency. It is calculated by multiplying the total supply of coins or tokens by the current market price of each individual coin or token. Market capitalization is a key metric used to measure the size and value of a cryptocurrency and used by investors to assess the attractiveness of a particular coin or token. It is also a useful tool for comparing different cryptocurrencies and determining which ones are the most valuable.",
@@ -918,12 +920,11 @@
"tokenized_community_comments_empty": "成为第一个加入对话的人",
"tokenized_community_comments_tab": "评论",
"tokenized_community_holders_tab": "持有者",
- "token_comment_holders_only": "评论仅对代币持有者开放。",
"tokenized_community_not_available_description": "已创建的帖子无法创建代币。",
"tokenized_community_not_available_title": "代币化内容错误",
+ "tokenized_community_token_content": "内容代币",
"tokenized_community_token_creator": "创作者代币",
"tokenized_community_token_twitter": "X 代币",
- "tokenized_community_token_content": "内容代币",
"tokenized_community_trades_tab": "交易",
"toolbar_link_placeholder": "https://example.com",
"toolbar_link_title": "添加链接",
diff --git a/packages/ion_token_analytics/lib/ion_token_analytics.dart b/packages/ion_token_analytics/lib/ion_token_analytics.dart
index f33fc15a86..f9ef6b00b2 100644
--- a/packages/ion_token_analytics/lib/ion_token_analytics.dart
+++ b/packages/ion_token_analytics/lib/ion_token_analytics.dart
@@ -11,5 +11,6 @@ export 'src/community_tokens/token_info/models/community_token_type.dart';
export 'src/community_tokens/token_info/models/models.dart';
export 'src/community_tokens/top_holders/models/models.dart';
export 'src/community_tokens/trading_stats/models/models.dart';
+export 'src/community_tokens/user_holdings/models/models.dart';
export 'src/core/network_client.dart' show NetworkSubscription;
export 'src/ion_token_analytics.dart';
diff --git a/packages/ion_token_analytics/lib/src/community_tokens/community_tokens_service.dart b/packages/ion_token_analytics/lib/src/community_tokens/community_tokens_service.dart
index ca47cfaaa2..d9c563154f 100644
--- a/packages/ion_token_analytics/lib/src/community_tokens/community_tokens_service.dart
+++ b/packages/ion_token_analytics/lib/src/community_tokens/community_tokens_service.dart
@@ -21,6 +21,8 @@ import 'package:ion_token_analytics/src/community_tokens/top_holders/top_holders
import 'package:ion_token_analytics/src/community_tokens/top_holders/top_holders_repository_impl.dart';
import 'package:ion_token_analytics/src/community_tokens/trading_stats/trading_stats_repository.dart';
import 'package:ion_token_analytics/src/community_tokens/trading_stats/trading_stats_repository_impl.dart';
+import 'package:ion_token_analytics/src/community_tokens/user_holdings/user_holdings_repository.dart';
+import 'package:ion_token_analytics/src/community_tokens/user_holdings/user_holdings_repository_impl.dart';
import 'package:ion_token_analytics/src/core/network_client.dart';
class IonCommunityTokensService {
@@ -35,6 +37,7 @@ class IonCommunityTokensService {
required CategoryTokensRepository categoryTokensRepository,
required GlobalSearchTokensRepository globalSearchTokensRepository,
required BondingCurveProgressRepository bondingCurveProgressRepository,
+ required UserHoldingsRepository userHoldingsRepository,
}) : _tokenInfoRepository = tokenInfoRepository,
_ohlcvCandlesRepository = ohlcvCandlesRepository,
_tradingStatsRepository = tradingStatsRepository,
@@ -44,7 +47,8 @@ class IonCommunityTokensService {
_latestTokensRepository = latestTokensRepository,
_categoryTokensRepository = categoryTokensRepository,
_globalSearchTokensRepository = globalSearchTokensRepository,
- _bondingCurveProgressRepository = bondingCurveProgressRepository;
+ _bondingCurveProgressRepository = bondingCurveProgressRepository,
+ _userHoldingsRepository = userHoldingsRepository;
final TokenInfoRepository _tokenInfoRepository;
final OhlcvCandlesRepository _ohlcvCandlesRepository;
@@ -56,6 +60,7 @@ class IonCommunityTokensService {
final CategoryTokensRepository _categoryTokensRepository;
final GlobalSearchTokensRepository _globalSearchTokensRepository;
final BondingCurveProgressRepository _bondingCurveProgressRepository;
+ final UserHoldingsRepository _userHoldingsRepository;
static Future create({required NetworkClient networkClient}) async {
final service = IonCommunityTokensService._(
@@ -69,6 +74,7 @@ class IonCommunityTokensService {
categoryTokensRepository: CategoryTokensRepositoryImpl(networkClient),
globalSearchTokensRepository: GlobalSearchTokensRepositoryRemote(networkClient),
bondingCurveProgressRepository: BondingCurveProgressRepositoryImpl(networkClient),
+ userHoldingsRepository: UserHoldingsRepositoryImpl(networkClient),
);
return service;
}
@@ -203,4 +209,12 @@ class IonCommunityTokensService {
Future getPricing(String externalAddress, String type, String amount) {
return _tokenInfoRepository.getPricing(externalAddress, type, amount);
}
+
+ Future getUserHoldings({
+ required String holder,
+ int limit = 20,
+ int offset = 0,
+ }) {
+ return _userHoldingsRepository.getUserHoldings(holder: holder, limit: limit, offset: offset);
+ }
}
diff --git a/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/models/models.dart b/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/models/models.dart
new file mode 100644
index 0000000000..ed84ab5129
--- /dev/null
+++ b/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/models/models.dart
@@ -0,0 +1,3 @@
+// SPDX-License-Identifier: ice License 1.0
+
+export 'user_holdings_data.dart';
diff --git a/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/models/user_holdings_data.dart b/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/models/user_holdings_data.dart
new file mode 100644
index 0000000000..ae4bf0089d
--- /dev/null
+++ b/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/models/user_holdings_data.dart
@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: ice License 1.0
+
+import 'package:ion_token_analytics/src/community_tokens/token_info/models/models.dart';
+
+class UserHoldingsData {
+ const UserHoldingsData({
+ required this.items,
+ required this.totalHoldings,
+ required this.nextOffset,
+ required this.hasMore,
+ });
+
+ final List items;
+ final int totalHoldings;
+ final int nextOffset;
+ final bool hasMore;
+}
diff --git a/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/user_holdings_repository.dart b/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/user_holdings_repository.dart
new file mode 100644
index 0000000000..1ceb62e5b4
--- /dev/null
+++ b/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/user_holdings_repository.dart
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: ice License 1.0
+
+import 'package:ion_token_analytics/src/community_tokens/user_holdings/models/models.dart';
+
+abstract class UserHoldingsRepository {
+ /// Fetches tokens that the specified holder has a position in.
+ ///
+ /// [holder] is the ionConnect address or twitter address of the user.
+ /// Returns tokens ordered by position amount (descending).
+ Future getUserHoldings({
+ required String holder,
+ int limit = 20,
+ int offset = 0,
+ });
+}
diff --git a/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/user_holdings_repository_impl.dart b/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/user_holdings_repository_impl.dart
new file mode 100644
index 0000000000..e0f5d5836f
--- /dev/null
+++ b/packages/ion_token_analytics/lib/src/community_tokens/user_holdings/user_holdings_repository_impl.dart
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: ice License 1.0
+
+import 'package:ion_token_analytics/src/community_tokens/token_info/models/models.dart';
+import 'package:ion_token_analytics/src/community_tokens/user_holdings/models/models.dart';
+import 'package:ion_token_analytics/src/community_tokens/user_holdings/user_holdings_repository.dart';
+import 'package:ion_token_analytics/src/core/network_client.dart';
+
+class UserHoldingsRepositoryImpl implements UserHoldingsRepository {
+ UserHoldingsRepositoryImpl(this._client);
+
+ final NetworkClient _client;
+
+ @override
+ Future getUserHoldings({
+ required String holder,
+ int limit = 20,
+ int offset = 0,
+ }) async {
+ final response = await _client.getWithResponse>(
+ '/v1/community-tokens/',
+ queryParameters: {'holder': holder, 'limit': limit, 'offset': offset},
+ );
+
+ final items = response.data
+ .map((e) => CommunityToken.fromJson(e as Map))
+ .toList();
+
+ final hasMore = items.length == limit;
+ final nextOffset = offset + items.length;
+
+ final totalHoldings = _extractTotalHoldings(response.headers, items.length);
+
+ return UserHoldingsData(
+ items: items,
+ totalHoldings: totalHoldings,
+ nextOffset: nextOffset,
+ hasMore: hasMore,
+ );
+ }
+
+ /// Extracts the total holdings count from response headers.
+ ///
+ /// Looks for the X_Total_Holdings header.
+ /// Falls back to [fallbackValue] if the header is missing or invalid.
+ int _extractTotalHoldings(Map? headers, int fallbackValue) {
+ if (headers == null) {
+ return fallbackValue;
+ }
+
+ final headerKey = headers.keys.firstWhere(
+ (key) => key.toLowerCase() == 'x_total_holdings',
+ orElse: () => '',
+ );
+
+ if (headerKey.isEmpty) {
+ return fallbackValue;
+ }
+
+ final headerValue = headers[headerKey];
+ if (headerValue == null) {
+ return fallbackValue;
+ }
+
+ final parsed = int.tryParse(headerValue);
+ return parsed ?? fallbackValue;
+ }
+}
diff --git a/packages/ion_token_analytics/lib/src/core/network_client.dart b/packages/ion_token_analytics/lib/src/core/network_client.dart
index d517907595..6fc42bedc2 100644
--- a/packages/ion_token_analytics/lib/src/core/network_client.dart
+++ b/packages/ion_token_analytics/lib/src/core/network_client.dart
@@ -29,6 +29,28 @@ class NetworkClient {
);
}
+ Future> getWithResponse(
+ String path, {
+ Map? queryParameters,
+ Map? headers,
+ }) async {
+ final response = await _client.request(
+ path,
+ queryParameters: _buildQueryParameters(queryParameters),
+ options: Http2RequestOptions(
+ timeout: _defaultTimeout,
+ headers: _addAuthorizationHeader(headers),
+ ),
+ );
+
+ if (response.statusCode != 200) {
+ //TODO: add custom exceptions with codes
+ throw Exception('Request failed with status ${response.statusCode}: $path');
+ }
+
+ return NetworkResponse(data: response.data as T, headers: response.headers);
+ }
+
Future post(
String path, {
Object? data,
@@ -182,6 +204,13 @@ class NetworkClient {
}
}
+class NetworkResponse {
+ NetworkResponse({required this.data, this.headers});
+
+ final T data;
+ final Map? headers;
+}
+
class NetworkSubscription {
NetworkSubscription({required this.stream, required this.close});