diff --git a/README.md b/README.md index 9d1481af..daa41758 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ Unless explicitly stated otherwise, the repository license applies to the source - Optional X-Ray opening mechanic - Souvenir packages with tournament-based dates - Skin pattern seed and finish variant support, including phase-aware finishes +- Persistent collection tracking with a saved inventory and recent activity +- Collection-aware glossary browsing with per-item collected counts +- Collection progress and per-source stats across containers and collections - Major tournament section covering CS:GO and CS2 eras - Major teams and players browsing - Operation and Armory reward collections @@ -145,23 +148,21 @@ The source code in this repository is licensed under `AGPL-3.0`. The project is actively evolving, with current work focused on: -- expanding the simulation layer beyond basic opening flows with deeper item metadata +- polishing the collection layer with stronger progress tracking across containers, collections, and collectibles - improving long-term data quality for tournaments, teams, and players - continuing UI/codebase refactoring to reduce duplicated screen logic -- preparing larger progression features such as collection tracking and ownership history +- hardening the simulator around long-term product polish rather than major missing content ## Roadmap -### v0.13 +### v0.14 -- Inventory or item ownership tracking in some form -- Opening and Trade-Up history with per-container and per-item stats -- Better collection browsing around owned items, seen drops, and completion progress +- Better automated test coverage for core simulator and collection logic +- Deeper collection analytics across items, duplicates, sessions, and finishing variants +- Cleaner navigation and final UI consistency pass across major app sections ### Future -- Cleaner navigation across containers, collections, and collectibles -- Better automated test coverage beyond basic smoke checks -- Music Kit preview playback if a reliable audio source is available -- Optional China / Perfect World visual mode if a reliable alternate asset source is available +- Music Kit preview playback if a maintainable preview-audio import pipeline becomes available +- Optional China / Perfect World visual mode once a reliable alternate asset source is available diff --git a/lib/core/collection/collection_entry.dart b/lib/core/collection/collection_entry.dart new file mode 100644 index 00000000..c05e61a0 --- /dev/null +++ b/lib/core/collection/collection_entry.dart @@ -0,0 +1,84 @@ +class CollectionEntry { + final String entryId; + final String category; + final String filterCategory; + final String itemId; + final String stackKey; + final String title; + final String subtitle; + final String imagePath; + final String rarity; + final String sourceName; + final String sourceType; + final DateTime acquiredAt; + final bool isStatTrak; + final bool isSouvenir; + final double? floatValue; + final String? exterior; + final int? patternSeed; + + const CollectionEntry({ + required this.entryId, + required this.category, + required this.filterCategory, + required this.itemId, + required this.stackKey, + required this.title, + required this.subtitle, + required this.imagePath, + required this.rarity, + required this.sourceName, + required this.sourceType, + required this.acquiredAt, + required this.isStatTrak, + required this.isSouvenir, + required this.floatValue, + required this.exterior, + required this.patternSeed, + }); + + factory CollectionEntry.fromJson(Map json) { + return CollectionEntry( + entryId: json['entryId'] as String, + category: json['category'] as String, + filterCategory: + (json['filterCategory'] as String?) ?? (json['category'] as String), + itemId: json['itemId'] as String, + stackKey: json['stackKey'] as String, + title: json['title'] as String, + subtitle: json['subtitle'] as String, + imagePath: json['imagePath'] as String, + rarity: json['rarity'] as String, + sourceName: json['sourceName'] as String? ?? '', + sourceType: json['sourceType'] as String? ?? '', + acquiredAt: DateTime.parse(json['acquiredAt'] as String), + isStatTrak: json['isStatTrak'] as bool? ?? false, + isSouvenir: json['isSouvenir'] as bool? ?? false, + floatValue: (json['floatValue'] as num?)?.toDouble(), + exterior: json['exterior'] as String?, + patternSeed: json['patternSeed'] as int?, + ); + } + + Map toJson() { + return { + 'entryId': entryId, + 'category': category, + 'filterCategory': filterCategory, + 'itemId': itemId, + 'stackKey': stackKey, + 'title': title, + 'subtitle': subtitle, + 'imagePath': imagePath, + 'rarity': rarity, + 'sourceName': sourceName, + 'sourceType': sourceType, + 'acquiredAt': acquiredAt.toIso8601String(), + 'isStatTrak': isStatTrak, + 'isSouvenir': isSouvenir, + 'floatValue': floatValue, + 'exterior': exterior, + 'patternSeed': patternSeed, + }; + } +} diff --git a/lib/core/collection/collection_summary.dart b/lib/core/collection/collection_summary.dart new file mode 100644 index 00000000..7685151b --- /dev/null +++ b/lib/core/collection/collection_summary.dart @@ -0,0 +1,33 @@ +import 'collection_entry.dart'; + +class CollectionSummary { + final String stackKey; + final String category; + final String filterCategory; + final String title; + final String subtitle; + final String imagePath; + final String rarity; + final int count; + final DateTime latestAcquiredAt; + final double? bestFloat; + final bool hasStatTrak; + final bool hasSouvenir; + final CollectionEntry latestEntry; + + const CollectionSummary({ + required this.stackKey, + required this.category, + required this.filterCategory, + required this.title, + required this.subtitle, + required this.imagePath, + required this.rarity, + required this.count, + required this.latestAcquiredAt, + required this.bestFloat, + required this.hasStatTrak, + required this.hasSouvenir, + required this.latestEntry, + }); +} diff --git a/lib/core/collection/collection_tracking_service.dart b/lib/core/collection/collection_tracking_service.dart new file mode 100644 index 00000000..fa56e5a5 --- /dev/null +++ b/lib/core/collection/collection_tracking_service.dart @@ -0,0 +1,370 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../data/models/skin_dto.dart'; +import '../../data/models/sticker_dto.dart'; +import '../../domain/dropped_agent.dart'; +import '../../domain/dropped_charm.dart'; +import '../../domain/dropped_graffiti.dart'; +import '../../domain/dropped_music_kit.dart'; +import '../../domain/dropped_patch.dart'; +import '../../domain/dropped_pin.dart'; +import '../../domain/dropped_skin.dart'; +import '../../domain/dropped_sticker.dart'; +import '../../presentation/helpers/agent_ui_helper.dart'; +import '../../presentation/helpers/charm_ui_helper.dart'; +import '../../presentation/helpers/graffiti_ui_helper.dart'; +import '../../presentation/helpers/music_kit_ui_helper.dart'; +import '../../presentation/helpers/patch_ui_helper.dart'; +import '../../presentation/helpers/pin_ui_helper.dart'; +import '../../presentation/helpers/skin_ui_helper.dart'; +import '../../presentation/helpers/sticker_ui_helper.dart'; +import 'collection_entry.dart'; +import 'collection_summary.dart'; + +class CollectionTrackingService { + static const _entriesKey = 'collection_entries_v1'; + static const _maxEntries = 5000; + + final SharedPreferencesAsync _prefs = SharedPreferencesAsync(); + final Random _random = Random(); + + Future> loadEntries() async { + final raw = await _prefs.getString(_entriesKey); + if (raw == null || raw.trim().isEmpty) { + return const []; + } + + final decoded = jsonDecode(raw); + if (decoded is! List) { + return const []; + } + + final entries = decoded + .whereType() + .map( + (item) => CollectionEntry.fromJson(Map.from(item)), + ) + .toList(); + entries.sort((a, b) => b.acquiredAt.compareTo(a.acquiredAt)); + return entries; + } + + Future> loadSummaries() async { + final entries = await loadEntries(); + final grouped = >{}; + for (final entry in entries) { + grouped.putIfAbsent(entry.stackKey, () => []).add(entry); + } + + final summaries = grouped.entries.map((entry) { + final items = List.from(entry.value) + ..sort((a, b) => b.acquiredAt.compareTo(a.acquiredAt)); + final latest = items.first; + final floatCandidates = items + .map((item) => item.floatValue) + .whereType() + .toList(); + + return CollectionSummary( + stackKey: latest.stackKey, + category: latest.category, + filterCategory: latest.filterCategory, + title: latest.title, + subtitle: latest.subtitle, + imagePath: latest.imagePath, + rarity: latest.rarity, + count: items.length, + latestAcquiredAt: latest.acquiredAt, + bestFloat: floatCandidates.isEmpty ? null : floatCandidates.reduce(min), + hasStatTrak: items.any((item) => item.isStatTrak), + hasSouvenir: items.any((item) => item.isSouvenir), + latestEntry: latest, + ); + }).toList(); + + summaries.sort((a, b) => b.latestAcquiredAt.compareTo(a.latestAcquiredAt)); + return summaries; + } + + Future loadSourceStats({ + required String sourceName, + required String sourceType, + }) async { + final entries = await loadEntries(); + final sourceEntries = entries + .where( + (item) => + item.sourceName == sourceName && item.sourceType == sourceType, + ) + .toList(); + + return CollectionSourceStats( + openedCount: sourceEntries.length, + collectedUniqueCount: sourceEntries + .map((item) => '${item.category}:${item.itemId}') + .toSet() + .length, + ); + } + + Future clearAll() async { + await _prefs.remove(_entriesKey); + } + + Future recordSkinDrop({ + required DroppedSkin drop, + required String sourceName, + required String sourceType, + }) async { + final skin = drop.skin; + final qualityPrefix = drop.isSouvenir + ? 'Souvenir ' + : drop.isStatTrak + ? 'StatTrakā„¢ ' + : ''; + + await _appendEntry( + CollectionEntry( + entryId: _entryId(), + category: 'skin', + filterCategory: _skinFilterCategory(skin), + itemId: skin.id, + stackKey: 'skin:${skin.id}:${drop.isStatTrak}:${drop.isSouvenir}', + title: '$qualityPrefix${skin.itemDisplayName}', + subtitle: SkinUiHelper.secondaryText(skin), + imagePath: skin.skinImage, + rarity: skin.rarity, + sourceName: sourceName, + sourceType: sourceType, + acquiredAt: DateTime.now(), + isStatTrak: drop.isStatTrak, + isSouvenir: drop.isSouvenir, + floatValue: drop.skinFloat, + exterior: drop.exterior, + patternSeed: drop.patternSeed, + ), + ); + } + + Future recordStickerDrop({ + required DroppedSticker drop, + required String sourceName, + required String sourceType, + }) async { + await _appendEntry( + _entryFromSticker( + sticker: drop.sticker, + sourceName: sourceName, + sourceType: sourceType, + ), + ); + } + + Future recordPinDrop({ + required DroppedPin drop, + required String sourceName, + required String sourceType, + }) async { + await _appendEntry( + _simpleEntry( + category: 'pin', + itemId: drop.pin.id, + title: drop.pin.name, + subtitle: PinUiHelper.secondaryText(drop.pin), + imagePath: drop.pin.pinImage, + rarity: drop.pin.rarity, + sourceName: sourceName, + sourceType: sourceType, + ), + ); + } + + Future recordMusicKitDrop({ + required DroppedMusicKit drop, + required String sourceName, + required String sourceType, + }) async { + await _appendEntry( + _simpleEntry( + category: 'music_kit', + itemId: drop.musicKit.id, + title: drop.musicKit.displayName, + subtitle: MusicKitUiHelper.secondaryText(drop.musicKit), + imagePath: drop.musicKit.musicKitImage, + rarity: drop.musicKit.rarity, + sourceName: sourceName, + sourceType: sourceType, + ), + ); + } + + Future recordAgentDrop({ + required DroppedAgent drop, + required String sourceName, + required String sourceType, + }) async { + await _appendEntry( + _simpleEntry( + category: 'agent', + itemId: drop.agent.id, + title: drop.agent.name, + subtitle: AgentUiHelper.secondaryText(drop.agent), + imagePath: drop.agent.agentImage, + rarity: drop.agent.rarity, + sourceName: sourceName, + sourceType: sourceType, + ), + ); + } + + Future recordGraffitiDrop({ + required DroppedGraffiti drop, + required String sourceName, + required String sourceType, + }) async { + await _appendEntry( + _simpleEntry( + category: 'graffiti', + itemId: drop.graffiti.id, + title: drop.graffiti.name, + subtitle: GraffitiUiHelper.secondaryText(drop.graffiti), + imagePath: drop.graffiti.graffitiImage, + rarity: drop.graffiti.rarity, + sourceName: sourceName, + sourceType: sourceType, + ), + ); + } + + Future recordPatchDrop({ + required DroppedPatch drop, + required String sourceName, + required String sourceType, + }) async { + await _appendEntry( + _simpleEntry( + category: 'patch', + itemId: drop.patch.id, + title: drop.patch.name, + subtitle: PatchUiHelper.secondaryText(drop.patch), + imagePath: drop.patch.patchImage, + rarity: drop.patch.rarity, + sourceName: sourceName, + sourceType: sourceType, + ), + ); + } + + Future recordCharmDrop({ + required DroppedCharm drop, + required String sourceName, + required String sourceType, + }) async { + await _appendEntry( + _simpleEntry( + category: 'charm', + itemId: drop.charm.id, + title: drop.charm.name, + subtitle: CharmUiHelper.secondaryText(drop.charm), + imagePath: drop.charm.charmImage, + rarity: drop.charm.rarity, + sourceName: sourceName, + sourceType: sourceType, + ), + ); + } + + Future _appendEntry(CollectionEntry entry) async { + final entries = List.from(await loadEntries()); + entries.insert(0, entry); + final trimmed = entries.take(_maxEntries).toList(); + await _prefs.setString( + _entriesKey, + jsonEncode(trimmed.map((item) => item.toJson()).toList()), + ); + } + + CollectionEntry _simpleEntry({ + required String category, + required String itemId, + required String title, + required String subtitle, + required String imagePath, + required String rarity, + required String sourceName, + required String sourceType, + }) { + return CollectionEntry( + entryId: _entryId(), + category: category, + filterCategory: category, + itemId: itemId, + stackKey: '$category:$itemId', + title: title, + subtitle: subtitle, + imagePath: imagePath, + rarity: rarity, + sourceName: sourceName, + sourceType: sourceType, + acquiredAt: DateTime.now(), + isStatTrak: false, + isSouvenir: false, + floatValue: null, + exterior: null, + patternSeed: null, + ); + } + + CollectionEntry _entryFromSticker({ + required StickerDto sticker, + required String sourceName, + required String sourceType, + }) { + return CollectionEntry( + entryId: _entryId(), + category: 'sticker', + filterCategory: 'sticker', + itemId: sticker.id, + stackKey: 'sticker:${sticker.id}', + title: sticker.name, + subtitle: StickerUiHelper.secondaryText(sticker), + imagePath: sticker.stickerImage, + rarity: sticker.rarity, + sourceName: sourceName, + sourceType: sourceType, + acquiredAt: DateTime.now(), + isStatTrak: false, + isSouvenir: false, + floatValue: null, + exterior: null, + patternSeed: null, + ); + } + + String _entryId() { + return '${DateTime.now().microsecondsSinceEpoch}_${_random.nextInt(0x7fffffff)}'; + } + + String _skinFilterCategory(SkinDto skin) { + if (skin.isKnife) { + return 'knife'; + } + if (skin.isGloves) { + return 'gloves'; + } + return 'skin'; + } +} + +class CollectionSourceStats { + final int openedCount; + final int collectedUniqueCount; + + const CollectionSourceStats({ + required this.openedCount, + required this.collectedUniqueCount, + }); +} diff --git a/lib/presentation/screens/agent_collection_list_screen.dart b/lib/presentation/screens/agent_collection_list_screen.dart index 57bd5820..2c0ed144 100644 --- a/lib/presentation/screens/agent_collection_list_screen.dart +++ b/lib/presentation/screens/agent_collection_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../data/models/container_dto.dart'; import '../../data/repositories/local_data_repository.dart'; import '../helpers/app_navigation_helper.dart'; @@ -7,6 +8,7 @@ import '../helpers/source_color_helper.dart'; import '../widgets/async_collection_loader.dart'; import '../widgets/chip_badge.dart'; import '../widgets/collection_list_card.dart'; +import '../widgets/collection_source_progress_metadata.dart'; import '../widgets/responsive_collection_grid.dart'; import 'agent_collection_open_screen.dart'; @@ -22,6 +24,8 @@ class AgentCollectionListScreen extends StatefulWidget { class _AgentCollectionListScreenState extends State { late Future> _future; + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); @override void initState() { @@ -47,6 +51,11 @@ class _AgentCollectionListScreenState extends State { fontWeight: FontWeight.w600, ), ), + CollectionSourceProgressMetadata( + container: collection, + repository: widget.repository, + trackingService: _collectionTracking, + ), ], onTap: () { AppNavigationHelper.pushScreen( diff --git a/lib/presentation/screens/agent_collection_open_screen.dart b/lib/presentation/screens/agent_collection_open_screen.dart index 42ad2f20..03832f74 100644 --- a/lib/presentation/screens/agent_collection_open_screen.dart +++ b/lib/presentation/screens/agent_collection_open_screen.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/agent_dto.dart'; import '../../data/models/container_dto.dart'; @@ -16,6 +17,7 @@ import '../widgets/collectible_open_body.dart'; import '../widgets/collectible_contents_title.dart'; import '../widgets/collectible_grid_sliver.dart'; import '../widgets/collectible_open_header.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/opening_loading_card.dart'; import '../widgets/source_badge.dart'; @@ -38,6 +40,8 @@ class _AgentCollectionOpenScreenState extends State { late Future> _agentsFuture; final AgentCollectionSimulatorService _simulator = AgentCollectionSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); final Random _random = Random(); DroppedAgent? _dropped; @@ -69,6 +73,11 @@ class _AgentCollectionOpenScreenState extends State { onComplete: (drop) { _dropped = drop; _isOpening = false; + _collectionTracking.recordAgentDrop( + drop: drop, + sourceName: widget.collection.name, + sourceType: widget.collection.typeLabel, + ); }, ); } @@ -98,6 +107,14 @@ class _AgentCollectionOpenScreenState extends State { color: color, ), ], + metadata: [ + CollectionSourceStatsWidget( + sourceName: widget.collection.name, + sourceType: widget.collection.typeLabel, + service: _collectionTracking, + totalCount: agents.length, + ), + ], releaseDateText: formattedReleaseDate, description: 'Agent collections open like operation rewards: no roulette, just the final reveal.', diff --git a/lib/presentation/screens/agent_details_screen.dart b/lib/presentation/screens/agent_details_screen.dart index 1f640742..89bc151b 100644 --- a/lib/presentation/screens/agent_details_screen.dart +++ b/lib/presentation/screens/agent_details_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_summary.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/agent_dto.dart'; import '../../data/models/container_dto.dart'; @@ -16,6 +18,8 @@ import 'agent_collection_open_screen.dart'; class AgentDetailsScreen extends StatelessWidget { final LocalDataRepository repository; final AgentDto agent; + static final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); const AgentDetailsScreen({ super.key, @@ -29,8 +33,8 @@ class AgentDetailsScreen extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(agent.name)), - body: FutureBuilder>( - future: repository.loadAgentCollectionsForAgent(agent.id), + body: FutureBuilder<_AgentDetailsData>( + future: _loadData(), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); @@ -48,7 +52,8 @@ class AgentDetailsScreen extends StatelessWidget { ); } - final collections = snapshot.data ?? const []; + final data = + snapshot.data ?? const _AgentDetailsData(collections: []); return ListView( padding: const EdgeInsets.all(12), @@ -81,6 +86,11 @@ class AgentDetailsScreen extends StatelessWidget { ? 'Counter-Terrorist' : 'Terrorist', ), + if (data.collectedCount > 0) + DetailInfoRow( + title: 'Collected', + value: '${data.collectedCount}', + ), if ((agent.collection ?? '').isNotEmpty) DetailInfoRow( title: 'Collection', @@ -91,7 +101,7 @@ class AgentDetailsScreen extends StatelessWidget { const SizedBox(height: 12), DetailSourceSection( title: 'Agent Collections', - items: collections, + items: data.collections, emptyText: 'No agent collection sources found.', itemBuilder: (item) => DetailSourceTile( imagePath: item.containerImage, @@ -117,4 +127,31 @@ class AgentDetailsScreen extends StatelessWidget { ), ); } + + Future<_AgentDetailsData> _loadData() async { + final results = await Future.wait([ + repository.loadAgentCollectionsForAgent(agent.id), + _collectionTracking.loadSummaries(), + ]); + + final summaries = results[1] as List; + final collectedCount = summaries + .where( + (item) => + item.category == 'agent' && item.latestEntry.itemId == agent.id, + ) + .fold(0, (sum, item) => sum + item.count); + + return _AgentDetailsData( + collections: results[0] as List, + collectedCount: collectedCount, + ); + } +} + +class _AgentDetailsData { + final List collections; + final int collectedCount; + + const _AgentDetailsData({required this.collections, this.collectedCount = 0}); } diff --git a/lib/presentation/screens/agent_glossary_screen.dart b/lib/presentation/screens/agent_glossary_screen.dart index a1ca4c75..5e8b3860 100644 --- a/lib/presentation/screens/agent_glossary_screen.dart +++ b/lib/presentation/screens/agent_glossary_screen.dart @@ -151,13 +151,18 @@ class _AgentGlossaryScreenState extends State { }, ), ], - itemBuilder: (context, agent) { + collectedCountBuilder: (agent, collectedByItemId) => + collectedByItemId[agent.id] ?? 0, + itemBuilder: (context, agent, collectedCount) { final color = AgentUiHelper.rarityColor(agent); return GlossaryListItem( accentColor: color, imagePath: agent.agentImage, title: agent.name, subtitle: AgentUiHelper.secondaryText(agent), + collectionInfo: collectedCount > 0 + ? 'Collected $collectedCount' + : null, tags: [ DetailTag(text: AgentUiHelper.rarityLabel(agent), color: color), DetailTag( diff --git a/lib/presentation/screens/charm_collection_list_screen.dart b/lib/presentation/screens/charm_collection_list_screen.dart index d3a173ee..c2b13e01 100644 --- a/lib/presentation/screens/charm_collection_list_screen.dart +++ b/lib/presentation/screens/charm_collection_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../data/models/container_dto.dart'; import '../../data/repositories/local_data_repository.dart'; import '../helpers/app_navigation_helper.dart'; @@ -7,6 +8,7 @@ import '../helpers/source_color_helper.dart'; import '../widgets/async_collection_loader.dart'; import '../widgets/chip_badge.dart'; import '../widgets/collection_list_card.dart'; +import '../widgets/collection_source_progress_metadata.dart'; import '../widgets/responsive_collection_grid.dart'; import 'charm_collection_open_screen.dart'; @@ -22,6 +24,8 @@ class CharmCollectionListScreen extends StatefulWidget { class _CharmCollectionListScreenState extends State { late Future> _future; + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); @override void initState() { @@ -57,6 +61,11 @@ class _CharmCollectionListScreenState extends State { ), ), ], + CollectionSourceProgressMetadata( + container: collection, + repository: widget.repository, + trackingService: _collectionTracking, + ), ], onTap: () { AppNavigationHelper.pushScreen( diff --git a/lib/presentation/screens/charm_collection_open_screen.dart b/lib/presentation/screens/charm_collection_open_screen.dart index 03af37d6..c856df42 100644 --- a/lib/presentation/screens/charm_collection_open_screen.dart +++ b/lib/presentation/screens/charm_collection_open_screen.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/charm_dto.dart'; @@ -17,6 +18,7 @@ import '../widgets/collectible_contents_title.dart'; import '../widgets/collectible_grid_sliver.dart'; import '../widgets/collectible_open_body.dart'; import '../widgets/collectible_open_header.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/opening_loading_card.dart'; class CharmCollectionOpenScreen extends StatefulWidget { @@ -38,6 +40,8 @@ class _CharmCollectionOpenScreenState extends State { late Future> _charmsFuture; final CharmCollectionSimulatorService _simulator = CharmCollectionSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); final Random _random = Random(); DroppedCharm? _dropped; @@ -66,6 +70,11 @@ class _CharmCollectionOpenScreenState extends State { onComplete: (drop) { _dropped = drop; _isOpening = false; + _collectionTracking.recordCharmDrop( + drop: drop, + sourceName: widget.collection.name, + sourceType: widget.collection.typeLabel, + ); }, ); } @@ -105,6 +114,12 @@ class _CharmCollectionOpenScreenState extends State { ), ], metadata: [ + CollectionSourceStatsWidget( + sourceName: widget.collection.name, + sourceType: widget.collection.typeLabel, + service: _collectionTracking, + totalCount: charms.length, + ), if ((widget.collection.sourceName ?? '').isNotEmpty) ...[ const SizedBox(height: 8), Text( diff --git a/lib/presentation/screens/charm_details_screen.dart b/lib/presentation/screens/charm_details_screen.dart index 64dc6888..8c32ead9 100644 --- a/lib/presentation/screens/charm_details_screen.dart +++ b/lib/presentation/screens/charm_details_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_summary.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/charm_dto.dart'; @@ -15,6 +17,8 @@ import '../widgets/detail_tag.dart'; class CharmDetailsScreen extends StatelessWidget { final LocalDataRepository repository; final CharmDto charm; + static final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); const CharmDetailsScreen({ super.key, @@ -28,8 +32,8 @@ class CharmDetailsScreen extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(charm.name)), - body: FutureBuilder>( - future: repository.loadContainersForCharm(charm.id), + body: FutureBuilder<_CharmDetailsData>( + future: _loadData(), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); @@ -47,7 +51,7 @@ class CharmDetailsScreen extends StatelessWidget { ); } - final cases = snapshot.data ?? const []; + final data = snapshot.data ?? const _CharmDetailsData(containers: []); return ListView( padding: const EdgeInsets.all(12), @@ -69,6 +73,11 @@ class CharmDetailsScreen extends StatelessWidget { title: 'Rarity', value: CharmUiHelper.rarityLabel(charm), ), + if (data.collectedCount > 0) + DetailInfoRow( + title: 'Collected', + value: '${data.collectedCount}', + ), if ((charm.collection ?? '').isNotEmpty) DetailInfoRow( title: 'Collection', @@ -79,7 +88,7 @@ class CharmDetailsScreen extends StatelessWidget { const SizedBox(height: 12), DetailSourceSection( title: 'Collections', - items: cases, + items: data.containers, emptyText: 'No charm collection sources found.', itemBuilder: (item) => DetailSourceTile( imagePath: item.containerImage, @@ -105,4 +114,31 @@ class CharmDetailsScreen extends StatelessWidget { ), ); } + + Future<_CharmDetailsData> _loadData() async { + final results = await Future.wait([ + repository.loadContainersForCharm(charm.id), + _collectionTracking.loadSummaries(), + ]); + + final summaries = results[1] as List; + final collectedCount = summaries + .where( + (item) => + item.category == 'charm' && item.latestEntry.itemId == charm.id, + ) + .fold(0, (sum, item) => sum + item.count); + + return _CharmDetailsData( + containers: results[0] as List, + collectedCount: collectedCount, + ); + } +} + +class _CharmDetailsData { + final List containers; + final int collectedCount; + + const _CharmDetailsData({required this.containers, this.collectedCount = 0}); } diff --git a/lib/presentation/screens/charm_glossary_screen.dart b/lib/presentation/screens/charm_glossary_screen.dart index 6096d79a..ad3ba58f 100644 --- a/lib/presentation/screens/charm_glossary_screen.dart +++ b/lib/presentation/screens/charm_glossary_screen.dart @@ -129,13 +129,18 @@ class _CharmGlossaryScreenState extends State { ], ), ], - itemBuilder: (context, charm) { + collectedCountBuilder: (charm, collectedByItemId) => + collectedByItemId[charm.id] ?? 0, + itemBuilder: (context, charm, collectedCount) { final color = CharmUiHelper.rarityColor(charm); return GlossaryListItem( accentColor: color, imagePath: charm.charmImage, title: charm.name, subtitle: CharmUiHelper.secondaryText(charm), + collectionInfo: collectedCount > 0 + ? 'Collected $collectedCount' + : null, tags: [ DetailTag(text: CharmUiHelper.rarityLabel(charm), color: color), if ((charm.collection ?? '').isNotEmpty) diff --git a/lib/presentation/screens/container_list_screen.dart b/lib/presentation/screens/container_list_screen.dart index e0c7d0e8..28d208af 100644 --- a/lib/presentation/screens/container_list_screen.dart +++ b/lib/presentation/screens/container_list_screen.dart @@ -1,14 +1,18 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/settings/settings_controller.dart'; import '../../data/models/container_dto.dart'; +import '../../data/models/skin_dto.dart'; import '../../data/repositories/local_data_repository.dart'; +import '../../domain/special_item_variant_helper.dart'; import '../helpers/app_navigation_helper.dart'; import '../helpers/source_color_helper.dart'; import '../widgets/async_collection_loader.dart'; import '../widgets/chip_badge.dart'; import '../widgets/collection_filter_bar.dart'; import '../widgets/collection_list_card.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/responsive_collection_grid.dart'; class ContainerListScreen extends StatefulWidget { @@ -27,6 +31,8 @@ class ContainerListScreen extends StatefulWidget { class _ContainerListScreenState extends State { late Future> _containersFuture; + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); static const String _filterAll = 'ALL'; String _selectedFilter = _filterAll; @@ -186,7 +192,7 @@ class _ContainerListScreenState extends State { title: containerDto.name, releaseDate: containerDto.releaseDate, chips: chips, - metadata: const [], + metadata: [_buildProgressMetadata(containerDto)], onTap: () { AppNavigationHelper.pushScreen( context, @@ -200,6 +206,83 @@ class _ContainerListScreenState extends State { ); } + Widget _buildProgressMetadata(ContainerDto containerDto) { + return FutureBuilder( + future: _loadTotalCount(containerDto), + builder: (context, snapshot) { + final totalCount = snapshot.data; + if (totalCount == null || totalCount <= 0) { + return const SizedBox.shrink(); + } + + return CollectionSourceStatsWidget( + sourceName: containerDto.name, + sourceType: containerDto.typeLabel, + service: _collectionTracking, + totalCount: totalCount, + compact: true, + ); + }, + ); + } + + Future _loadTotalCount(ContainerDto containerDto) async { + switch (containerDto.type) { + case 'CASE': + case 'SOUVENIR_PACKAGE': + case 'COLLECTION_PACKAGE': + case 'TERMINAL': + final skins = await widget.repository.loadSkinsForContainer( + containerDto.id, + ); + return _groupedSkinFamilyCount(skins); + case 'STICKER_CAPSULE': + final stickers = await widget.repository.loadStickersForContainer( + containerDto.id, + ); + return stickers.length; + case 'PIN_CAPSULE': + final pins = await widget.repository.loadPinsForContainer( + containerDto.id, + ); + return pins.length; + case 'MUSIC_KIT_BOX': + final musicKits = await widget.repository.loadMusicKitsForContainer( + containerDto.id, + ); + return musicKits.length; + case 'GRAFFITI_BOX': + final graffiti = await widget.repository.loadGraffitiForContainer( + containerDto.id, + ); + return graffiti.length; + case 'PATCH_PACK': + final patches = await widget.repository.loadPatchesForContainer( + containerDto.id, + ); + return patches.length; + default: + return 0; + } + } + + int _groupedSkinFamilyCount(List skins) { + final families = >{}; + for (final skin in skins) { + final key = SpecialItemVariantHelper.familyKeyForSkin(skin); + families.putIfAbsent(key, () => []).add(skin); + } + + var count = 0; + for (final family in families.values) { + final shouldGroup = + family.length > 1 && + SpecialItemVariantHelper.hasConfiguredVariantWeights(family); + count += shouldGroup ? 1 : family.length; + } + return count; + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/presentation/screens/container_open_screen.dart b/lib/presentation/screens/container_open_screen.dart index 4a8bbca7..19dac3ff 100644 --- a/lib/presentation/screens/container_open_screen.dart +++ b/lib/presentation/screens/container_open_screen.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/settings/settings_controller.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; @@ -19,6 +20,7 @@ import '../widgets/collectible_grid_sliver.dart'; import '../widgets/collectible_open_body.dart'; import '../widgets/collectible_open_header.dart'; import '../widgets/collectible_roller_sliver.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/opening_roll_item_card.dart'; import '../widgets/opening_roller.dart'; import '../widgets/skin_drop_card.dart'; @@ -44,6 +46,8 @@ class ContainerOpenScreen extends StatefulWidget { class _ContainerOpenScreenState extends State { late Future> _skinsFuture; final ContainerSimulatorService _simulator = ContainerSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); final Random _random = Random(); final ScrollController _rollController = ScrollController(); @@ -104,6 +108,11 @@ class _ContainerOpenScreenState extends State { _isRolling = false; _resetXrayState(); }); + await _collectionTracking.recordSkinDrop( + drop: drop, + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + ); return; } @@ -160,6 +169,11 @@ class _ContainerOpenScreenState extends State { _dropped = drop; _isRolling = false; }); + await _collectionTracking.recordSkinDrop( + drop: drop, + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + ); } Future _claimXrayDrop() async { @@ -172,6 +186,11 @@ class _ContainerOpenScreenState extends State { _pendingXrayDrop = null; _xrayRevealActive = false; }); + await _collectionTracking.recordSkinDrop( + drop: _dropped!, + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + ); } Future _destroyXrayDrop() async { @@ -422,6 +441,14 @@ class _ContainerOpenScreenState extends State { color: typeColor, ), ], + metadata: [ + CollectionSourceStatsWidget( + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + service: _collectionTracking, + totalCount: displayedContents.length, + ), + ], releaseDateText: formattedReleaseDate, description: _headerDescription(skins), buttonLabel: _openButtonLabel(), diff --git a/lib/presentation/screens/graffiti_box_open_screen.dart b/lib/presentation/screens/graffiti_box_open_screen.dart index 60932ffa..00ab99e0 100644 --- a/lib/presentation/screens/graffiti_box_open_screen.dart +++ b/lib/presentation/screens/graffiti_box_open_screen.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/graffiti_dto.dart'; @@ -17,6 +18,7 @@ import '../widgets/collectible_contents_title.dart'; import '../widgets/collectible_grid_sliver.dart'; import '../widgets/collectible_open_header.dart'; import '../widgets/collectible_roller_sliver.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/graffiti_drop_card.dart'; import '../widgets/graffiti_grid_tile.dart'; import '../widgets/opening_roll_item_card.dart'; @@ -38,6 +40,8 @@ class GraffitiBoxOpenScreen extends StatefulWidget { class _GraffitiBoxOpenScreenState extends State { late Future> _graffitiFuture; final GraffitiSimulatorService _simulator = GraffitiSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); final Random _random = Random(); final ScrollController _rollController = ScrollController(); @@ -80,6 +84,11 @@ class _GraffitiBoxOpenScreenState extends State { onComplete: (drop) { _dropped = drop; _isOpening = false; + _collectionTracking.recordGraffitiDrop( + drop: drop, + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + ); }, ); } @@ -154,6 +163,14 @@ class _GraffitiBoxOpenScreenState extends State { badges: [ ChipBadge(label: widget.containerDto.typeLabel, color: color), ], + metadata: [ + CollectionSourceStatsWidget( + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + service: _collectionTracking, + totalCount: graffiti.length, + ), + ], releaseDateText: formattedReleaseDate, description: 'Graffiti boxes use roulette opening and are not affected by X-Ray mode.', diff --git a/lib/presentation/screens/graffiti_details_screen.dart b/lib/presentation/screens/graffiti_details_screen.dart index b7ac5f9b..6a419d63 100644 --- a/lib/presentation/screens/graffiti_details_screen.dart +++ b/lib/presentation/screens/graffiti_details_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_summary.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/graffiti_dto.dart'; @@ -15,6 +17,8 @@ import '../widgets/detail_tag.dart'; class GraffitiDetailsScreen extends StatelessWidget { final LocalDataRepository repository; final GraffitiDto graffiti; + static final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); const GraffitiDetailsScreen({ super.key, @@ -28,8 +32,8 @@ class GraffitiDetailsScreen extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(graffiti.name)), - body: FutureBuilder>( - future: repository.loadContainersForGraffiti(graffiti.id), + body: FutureBuilder<_GraffitiDetailsData>( + future: _loadData(), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); @@ -47,7 +51,8 @@ class GraffitiDetailsScreen extends StatelessWidget { ); } - final cases = snapshot.data ?? const []; + final data = + snapshot.data ?? const _GraffitiDetailsData(containers: []); return ListView( padding: const EdgeInsets.all(12), @@ -69,6 +74,11 @@ class GraffitiDetailsScreen extends StatelessWidget { title: 'Rarity', value: GraffitiUiHelper.rarityLabel(graffiti), ), + if (data.collectedCount > 0) + DetailInfoRow( + title: 'Collected', + value: '${data.collectedCount}', + ), if ((graffiti.collection ?? '').isNotEmpty) DetailInfoRow( title: 'Collection', @@ -79,7 +89,7 @@ class GraffitiDetailsScreen extends StatelessWidget { const SizedBox(height: 12), DetailSourceSection( title: 'Boxes', - items: cases, + items: data.containers, emptyText: 'No graffiti box sources found.', itemBuilder: (item) => DetailSourceTile( imagePath: item.containerImage, @@ -105,4 +115,35 @@ class GraffitiDetailsScreen extends StatelessWidget { ), ); } + + Future<_GraffitiDetailsData> _loadData() async { + final results = await Future.wait([ + repository.loadContainersForGraffiti(graffiti.id), + _collectionTracking.loadSummaries(), + ]); + + final summaries = results[1] as List; + final collectedCount = summaries + .where( + (item) => + item.category == 'graffiti' && + item.latestEntry.itemId == graffiti.id, + ) + .fold(0, (sum, item) => sum + item.count); + + return _GraffitiDetailsData( + containers: results[0] as List, + collectedCount: collectedCount, + ); + } +} + +class _GraffitiDetailsData { + final List containers; + final int collectedCount; + + const _GraffitiDetailsData({ + required this.containers, + this.collectedCount = 0, + }); } diff --git a/lib/presentation/screens/graffiti_glossary_screen.dart b/lib/presentation/screens/graffiti_glossary_screen.dart index d394816b..75ae8bac 100644 --- a/lib/presentation/screens/graffiti_glossary_screen.dart +++ b/lib/presentation/screens/graffiti_glossary_screen.dart @@ -129,13 +129,18 @@ class _GraffitiGlossaryScreenState extends State { ], ), ], - itemBuilder: (context, graffiti) { + collectedCountBuilder: (graffiti, collectedByItemId) => + collectedByItemId[graffiti.id] ?? 0, + itemBuilder: (context, graffiti, collectedCount) { final color = GraffitiUiHelper.rarityColor(graffiti); return GlossaryListItem( accentColor: color, imagePath: graffiti.graffitiImage, title: graffiti.name, subtitle: GraffitiUiHelper.secondaryText(graffiti), + collectionInfo: collectedCount > 0 + ? 'Collected $collectedCount' + : null, tags: [ DetailTag( text: GraffitiUiHelper.rarityLabel(graffiti), diff --git a/lib/presentation/screens/home_screen.dart b/lib/presentation/screens/home_screen.dart index 4b22e9ec..6ab5ff02 100644 --- a/lib/presentation/screens/home_screen.dart +++ b/lib/presentation/screens/home_screen.dart @@ -8,6 +8,7 @@ import 'agent_collection_list_screen.dart'; import 'charm_collection_list_screen.dart'; import 'container_list_screen.dart'; import 'glossary_hub_screen.dart'; +import 'my_collection_screen.dart'; import 'operation_collection_list_screen.dart'; import 'patch_collection_list_screen.dart'; import 'player_list_screen.dart'; @@ -74,6 +75,13 @@ class HomeScreen extends StatelessWidget { ), onTradeUp: () => _push(context, TradeUpScreen(repository: repository)), + onCollection: () => _push( + context, + MyCollectionScreen( + repository: repository, + settingsController: settingsController, + ), + ), ), const SizedBox(height: 16), _ResponsiveSectionGrid( @@ -245,11 +253,13 @@ class _HeroSection extends StatelessWidget { final VoidCallback onOpenContainers; final VoidCallback onGlossary; final VoidCallback onTradeUp; + final VoidCallback onCollection; const _HeroSection({ required this.onOpenContainers, required this.onGlossary, required this.onTradeUp, + required this.onCollection, }); @override @@ -283,7 +293,7 @@ class _HeroSection extends StatelessWidget { ), const SizedBox(height: 8), Text( - 'Containers, trade-ups, pattern-aware skin browsing, and full Major tournament history all live here now.', + 'Containers, trade-ups, collection tracking, pattern-aware skin browsing, and full Major tournament history all live here now.', style: theme.textTheme.bodyMedium?.copyWith( color: Colors.white70, height: 1.4, @@ -310,6 +320,11 @@ class _HeroSection extends StatelessWidget { title: 'Trade-Up', onTap: onTradeUp, ), + _HeroButton( + icon: Icons.inventory_2_outlined, + title: 'My Collection', + onTap: onCollection, + ), ], ), ], diff --git a/lib/presentation/screens/music_kit_box_open_screen.dart b/lib/presentation/screens/music_kit_box_open_screen.dart index 0c2d7f5f..31824531 100644 --- a/lib/presentation/screens/music_kit_box_open_screen.dart +++ b/lib/presentation/screens/music_kit_box_open_screen.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/music_kit_dto.dart'; @@ -17,6 +18,7 @@ import '../widgets/collectible_contents_title.dart'; import '../widgets/collectible_grid_sliver.dart'; import '../widgets/collectible_open_header.dart'; import '../widgets/collectible_roller_sliver.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/music_kit_drop_card.dart'; import '../widgets/music_kit_grid_tile.dart'; import '../widgets/opening_roll_item_card.dart'; @@ -38,6 +40,8 @@ class MusicKitBoxOpenScreen extends StatefulWidget { class _MusicKitBoxOpenScreenState extends State { late Future> _musicKitsFuture; final MusicKitSimulatorService _simulator = MusicKitSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); final Random _random = Random(); final ScrollController _rollController = ScrollController(); @@ -80,6 +84,11 @@ class _MusicKitBoxOpenScreenState extends State { onComplete: (drop) { _dropped = drop; _isOpening = false; + _collectionTracking.recordMusicKitDrop( + drop: drop, + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + ); }, ); } @@ -135,6 +144,14 @@ class _MusicKitBoxOpenScreenState extends State { badges: [ ChipBadge(label: widget.containerDto.typeLabel, color: color), ], + metadata: [ + CollectionSourceStatsWidget( + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + service: _collectionTracking, + totalCount: musicKits.length, + ), + ], releaseDateText: formattedReleaseDate, description: 'Music Kit Boxes roll music kits only. StatTrak variants are supported through their dedicated boxes.', diff --git a/lib/presentation/screens/music_kit_details_screen.dart b/lib/presentation/screens/music_kit_details_screen.dart index 0849617d..f779d866 100644 --- a/lib/presentation/screens/music_kit_details_screen.dart +++ b/lib/presentation/screens/music_kit_details_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_summary.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/music_kit_dto.dart'; @@ -17,6 +19,8 @@ class MusicKitDetailsScreen extends StatelessWidget { final LocalDataRepository repository; final String musicKitName; final String? collection; + static final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); const MusicKitDetailsScreen({ super.key, @@ -95,8 +99,13 @@ class MusicKitDetailsScreen extends StatelessWidget { title: 'Type', value: MusicKitUiHelper.groupedTypeLabel(group), ), + if (data.collectedCount > 0) + DetailInfoRow( + title: 'Collected', + value: '${data.collectedCount}', + ), DetailInfoRow( - title: 'StatTrakā„¢ variant', + title: 'StatTrak variant', value: _statTrakAvailabilityLabel( hasRegular: group.hasRegular, hasStatTrak: group.hasStatTrak, @@ -138,12 +147,28 @@ class MusicKitDetailsScreen extends StatelessWidget { Future<_MusicKitDetailsData> _loadData() async { final group = await repository.loadMusicKitGroup(musicKitName, collection); - final containers = await repository.loadContainersForMusicKitGroup( - musicKitName, - collection, - ); + final results = await Future.wait([ + repository.loadContainersForMusicKitGroup(musicKitName, collection), + _collectionTracking.loadSummaries(), + ]); + + final summaries = results[1] as List; + final variantIds = (group?.variants ?? const []) + .map((item) => item.id) + .toSet(); + final collectedCount = summaries + .where( + (item) => + item.category == 'music_kit' && + variantIds.contains(item.latestEntry.itemId), + ) + .fold(0, (sum, item) => sum + item.count); - return _MusicKitDetailsData(group: group, containers: containers); + return _MusicKitDetailsData( + group: group, + containers: results[0] as List, + collectedCount: collectedCount, + ); } String _titleFromName(String fullName) { @@ -176,6 +201,11 @@ class MusicKitDetailsScreen extends StatelessWidget { class _MusicKitDetailsData { final MusicKitGroupDto? group; final List containers; + final int collectedCount; - const _MusicKitDetailsData({required this.group, required this.containers}); + const _MusicKitDetailsData({ + required this.group, + required this.containers, + this.collectedCount = 0, + }); } diff --git a/lib/presentation/screens/music_kit_glossary_screen.dart b/lib/presentation/screens/music_kit_glossary_screen.dart index 969bf531..b4fa2570 100644 --- a/lib/presentation/screens/music_kit_glossary_screen.dart +++ b/lib/presentation/screens/music_kit_glossary_screen.dart @@ -146,13 +146,21 @@ class _MusicKitGlossaryScreenState extends State { ], ), ], - itemBuilder: (context, musicKit) { + collectedCountBuilder: (musicKit, collectedByItemId) => + musicKit.variants.fold( + 0, + (sum, variant) => sum + (collectedByItemId[variant.id] ?? 0), + ), + itemBuilder: (context, musicKit, collectedCount) { final color = MusicKitUiHelper.rarityColor(musicKit.primary); return GlossaryListItem( accentColor: color, imagePath: musicKit.imagePath, title: musicKit.trackName, subtitle: MusicKitUiHelper.groupedSecondaryText(musicKit), + collectionInfo: collectedCount > 0 + ? 'Collected $collectedCount' + : null, tags: [ DetailTag( text: MusicKitUiHelper.rarityLabel(musicKit.primary), diff --git a/lib/presentation/screens/my_collection_screen.dart b/lib/presentation/screens/my_collection_screen.dart new file mode 100644 index 00000000..1e00a8a9 --- /dev/null +++ b/lib/presentation/screens/my_collection_screen.dart @@ -0,0 +1,1448 @@ +import 'package:flutter/material.dart'; + +import '../../core/collection/collection_entry.dart'; +import '../../core/collection/collection_summary.dart'; +import '../../core/collection/collection_tracking_service.dart'; +import '../../core/settings/settings_controller.dart'; +import '../../data/repositories/local_data_repository.dart'; +import '../helpers/app_navigation_helper.dart'; +import 'agent_details_screen.dart'; +import 'charm_details_screen.dart'; +import 'graffiti_details_screen.dart'; +import 'music_kit_details_screen.dart'; +import 'patch_details_screen.dart'; +import 'pin_details_screen.dart'; +import 'skin_details_screen.dart'; +import 'sticker_details_screen.dart'; + +class MyCollectionScreen extends StatefulWidget { + final LocalDataRepository repository; + final SettingsController settingsController; + + const MyCollectionScreen({ + super.key, + required this.repository, + required this.settingsController, + }); + + @override + State createState() => _MyCollectionScreenState(); +} + +class _MyCollectionScreenState extends State { + final CollectionTrackingService _service = CollectionTrackingService(); + + late Future<_CollectionData> _dataFuture; + String _inventorySearch = ''; + String _inventoryCategory = 'ALL'; + String _inventoryRarity = 'ALL'; + String _inventoryStatTrak = 'ALL'; + String _inventorySort = 'LATEST'; + + @override + void initState() { + super.initState(); + _dataFuture = _loadData(); + } + + Future<_CollectionData> _loadData() async { + final results = await Future.wait([ + _service.loadEntries(), + _service.loadSummaries(), + _loadProgress(), + ]); + + return _CollectionData( + entries: results[0] as List, + summaries: results[1] as List, + progress: results[2] as List<_CollectionProgressItem>, + ); + } + + Future> _loadProgress() async { + final results = await Future.wait([ + _service.loadSummaries(), + widget.repository.loadSkinGroups(), + widget.repository.loadStickers(), + widget.repository.loadPins(), + widget.repository.loadGroupedMusicKits(), + widget.repository.loadAgents(), + widget.repository.loadGraffiti(), + widget.repository.loadPatches(), + widget.repository.loadCharms(), + ]); + + final summaries = results[0] as List; + final skinGroups = results[1] as List; + final stickers = results[2] as List; + final pins = results[3] as List; + final musicKits = results[4] as List; + final agents = results[5] as List; + final graffiti = results[6] as List; + final patches = results[7] as List; + final charms = results[8] as List; + + final collectedByCategory = >{}; + for (final summary in summaries) { + collectedByCategory + .putIfAbsent(summary.category, () => {}) + .add(summary.latestEntry.itemId); + collectedByCategory + .putIfAbsent(summary.filterCategory, () => {}) + .add(summary.latestEntry.itemId); + } + + int groupedCollectedCount( + Iterable groups, + String category, + bool Function(dynamic group) predicate, + ) { + final collected = collectedByCategory[category] ?? const {}; + return groups.where(predicate).where((group) { + final variants = (group.variants as List) + .map((variant) => variant.id as String) + .toSet(); + return variants.any(collected.contains); + }).length; + } + + return [ + _CollectionProgressItem( + key: 'skin', + label: 'Skins', + icon: Icons.menu_book, + collected: groupedCollectedCount( + skinGroups, + 'skin', + (group) => group.itemKind == 'WEAPON', + ), + total: skinGroups.where((group) => group.itemKind == 'WEAPON').length, + ), + _CollectionProgressItem( + key: 'knife', + label: 'Knives', + icon: Icons.content_cut, + collected: groupedCollectedCount( + skinGroups, + 'knife', + (group) => group.itemKind == 'KNIFE', + ), + total: skinGroups.where((group) => group.itemKind == 'KNIFE').length, + ), + _CollectionProgressItem( + key: 'gloves', + label: 'Gloves', + icon: Icons.back_hand_outlined, + collected: groupedCollectedCount( + skinGroups, + 'gloves', + (group) => group.itemKind == 'GLOVES', + ), + total: skinGroups.where((group) => group.itemKind == 'GLOVES').length, + ), + _CollectionProgressItem( + key: 'sticker', + label: 'Stickers', + icon: Icons.sell, + collected: (collectedByCategory['sticker'] ?? const {}).length, + total: stickers.length, + ), + _CollectionProgressItem( + key: 'pin', + label: 'Pins', + icon: Icons.push_pin, + collected: (collectedByCategory['pin'] ?? const {}).length, + total: pins.length, + ), + _CollectionProgressItem( + key: 'music_kit', + label: 'Music Kits', + icon: Icons.library_music, + collected: musicKits.where((group) { + final collected = + collectedByCategory['music_kit'] ?? const {}; + final variants = (group.variants as List) + .map((variant) => variant.id as String) + .toSet(); + return variants.any(collected.contains); + }).length, + total: musicKits.length, + ), + _CollectionProgressItem( + key: 'agent', + label: 'Agents', + icon: Icons.badge, + collected: (collectedByCategory['agent'] ?? const {}).length, + total: agents.length, + ), + _CollectionProgressItem( + key: 'graffiti', + label: 'Graffiti', + icon: Icons.brush, + collected: (collectedByCategory['graffiti'] ?? const {}).length, + total: graffiti.length, + ), + _CollectionProgressItem( + key: 'patch', + label: 'Patches', + icon: Icons.style, + collected: (collectedByCategory['patch'] ?? const {}).length, + total: patches.length, + ), + _CollectionProgressItem( + key: 'charm', + label: 'Charms', + icon: Icons.key, + collected: (collectedByCategory['charm'] ?? const {}).length, + total: charms.length, + ), + ]; + } + + Future _refresh() async { + final future = _loadData(); + setState(() { + _dataFuture = future; + }); + await future; + } + + Future _clearAll() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear collection data?'), + content: const Text( + 'This will remove all saved collection items and recent activity.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Clear'), + ), + ], + ), + ); + + if (confirmed != true) { + return; + } + + await _service.clearAll(); + await _refresh(); + } + + List _filterInventory(List summaries) { + final query = _inventorySearch.trim().toLowerCase(); + + final filtered = summaries.where((summary) { + if (_inventoryCategory != 'ALL' && + summary.filterCategory != _inventoryCategory) { + return false; + } + + if (_inventoryRarity != 'ALL' && summary.rarity != _inventoryRarity) { + return false; + } + + if (_inventoryStatTrak == 'ONLY' && !summary.hasStatTrak) { + return false; + } + + if (_inventoryStatTrak == 'NONE' && summary.hasStatTrak) { + return false; + } + + if (query.isEmpty) { + return true; + } + + return summary.title.toLowerCase().contains(query) || + summary.subtitle.toLowerCase().contains(query) || + summary.latestEntry.sourceName.toLowerCase().contains(query) || + summary.categoryLabel.toLowerCase().contains(query); + }).toList(); + + filtered.sort((a, b) { + switch (_inventorySort) { + case 'A_Z': + return a.title.compareTo(b.title); + case 'MOST_OWNED': + final countCompare = b.count.compareTo(a.count); + if (countCompare != 0) { + return countCompare; + } + return b.latestAcquiredAt.compareTo(a.latestAcquiredAt); + case 'BEST_FLOAT': + final aFloat = a.bestFloat ?? 999; + final bFloat = b.bestFloat ?? 999; + final floatCompare = aFloat.compareTo(bFloat); + if (floatCompare != 0) { + return floatCompare; + } + return a.title.compareTo(b.title); + case 'LATEST': + default: + return b.latestAcquiredAt.compareTo(a.latestAcquiredAt); + } + }); + + return filtered; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('My Collection'), + actions: [ + IconButton( + tooltip: 'Recent activity', + onPressed: () { + AppNavigationHelper.pushScreen( + context, + CollectionHistoryScreen( + repository: widget.repository, + settingsController: widget.settingsController, + ), + ); + }, + icon: const Icon(Icons.history), + ), + IconButton( + tooltip: 'Refresh', + onPressed: _refresh, + icon: const Icon(Icons.refresh), + ), + IconButton( + tooltip: 'Clear all', + onPressed: _clearAll, + icon: const Icon(Icons.delete_outline), + ), + ], + ), + body: FutureBuilder<_CollectionData>( + future: _dataFuture, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + final data = snapshot.data!; + final filteredSummaries = _filterInventory(data.summaries); + final totalCollected = data.progress.fold( + 0, + (sum, item) => sum + item.collected, + ); + final totalAvailable = data.progress.fold( + 0, + (sum, item) => sum + item.total, + ); + + return Column( + children: [ + _CollectionOverview( + totalEntries: data.entries.length, + uniqueItems: data.summaries.length, + totalCollected: totalCollected, + totalAvailable: totalAvailable, + ), + _CollectionProgressSection(items: data.progress), + Expanded( + child: _InventoryTab( + summaries: filteredSummaries, + totalCount: data.summaries.length, + search: _inventorySearch, + selectedCategory: _inventoryCategory, + selectedRarity: _inventoryRarity, + selectedStatTrak: _inventoryStatTrak, + selectedSort: _inventorySort, + onSearchChanged: (value) { + setState(() { + _inventorySearch = value; + }); + }, + onCategoryChanged: (value) { + setState(() { + _inventoryCategory = value; + }); + }, + onRarityChanged: (value) { + setState(() { + _inventoryRarity = value; + }); + }, + onStatTrakChanged: (value) { + setState(() { + _inventoryStatTrak = value; + }); + }, + onSortChanged: (value) { + setState(() { + _inventorySort = value; + }); + }, + onItemTap: _openSummary, + ), + ), + ], + ); + }, + ), + ); + } +} + +class CollectionHistoryScreen extends StatefulWidget { + final LocalDataRepository repository; + final SettingsController settingsController; + + const CollectionHistoryScreen({ + super.key, + required this.repository, + required this.settingsController, + }); + + @override + State createState() => + _CollectionHistoryScreenState(); +} + +class _CollectionHistoryScreenState extends State { + final CollectionTrackingService _service = CollectionTrackingService(); + late Future> _future; + + @override + void initState() { + super.initState(); + _future = _service.loadEntries(); + } + + Future _refresh() async { + final future = _service.loadEntries(); + setState(() { + _future = future; + }); + await future; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Recent Activity'), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: _refresh, + icon: const Icon(Icons.refresh), + ), + ], + ), + body: FutureBuilder>( + future: _future, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Center(child: CircularProgressIndicator()); + } + + return _HistoryTab(entries: snapshot.data!, onItemTap: _openEntry); + }, + ), + ); + } + + Future _openEntry(CollectionEntry entry) async { + final screen = await _buildDetailsScreen( + repository: widget.repository, + settingsController: widget.settingsController, + category: entry.category, + itemId: entry.itemId, + ); + + if (!mounted) { + return; + } + + if (screen == null) { + _showMissingItemMessage(); + return; + } + + AppNavigationHelper.pushScreen(context, screen); + } + + void _showMissingItemMessage() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'This item is no longer available in the current glossary data.', + ), + ), + ); + } +} + +class _CollectionOverview extends StatelessWidget { + final int totalEntries; + final int uniqueItems; + final int totalCollected; + final int totalAvailable; + + const _CollectionOverview({ + required this.totalEntries, + required this.uniqueItems, + required this.totalCollected, + required this.totalAvailable, + }); + + @override + Widget build(BuildContext context) { + final completion = totalAvailable == 0 + ? 0 + : totalCollected / totalAvailable; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _StatChip( + icon: Icons.inventory_2_outlined, + label: 'Collected entries', + value: '$totalEntries', + ), + _StatChip( + icon: Icons.collections_bookmark_outlined, + label: 'Unique items', + value: '$uniqueItems', + ), + _StatChip( + icon: Icons.checklist_outlined, + label: 'Overall completion', + value: + '$totalCollected / $totalAvailable (${(completion * 100).floor()}%)', + ), + ], + ), + ), + ), + ); + } +} + +class _CollectionProgressSection extends StatelessWidget { + final List<_CollectionProgressItem> items; + + const _CollectionProgressSection({required this.items}); + + @override + Widget build(BuildContext context) { + if (items.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Collection Progress', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 10, + children: items + .map((item) => _ProgressTile(item: item)) + .toList(), + ), + ], + ), + ), + ), + ); + } +} + +class _ProgressTile extends StatelessWidget { + final _CollectionProgressItem item; + + const _ProgressTile({required this.item}); + + @override + Widget build(BuildContext context) { + final total = item.total == 0 ? 1 : item.total; + final progress = item.collected / total; + + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 170, maxWidth: 220), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white10), + color: Colors.white.withValues(alpha: 0.03), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(item.icon, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + item.label, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + Text( + '${item.collected}/${item.total}', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: progress.clamp(0, 1), + minHeight: 8, + backgroundColor: Colors.white10, + ), + ), + const SizedBox(height: 8), + Text( + '${(progress * 100).floor()}% complete', + style: const TextStyle(color: Colors.white54, fontSize: 12), + ), + ], + ), + ), + ); + } +} + +class _StatChip extends StatelessWidget { + final IconData icon; + final String label; + final String value; + + const _StatChip({ + required this.icon, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + Text( + value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _InventoryTab extends StatelessWidget { + final List summaries; + final int totalCount; + final String search; + final String selectedCategory; + final String selectedRarity; + final String selectedStatTrak; + final String selectedSort; + final ValueChanged onSearchChanged; + final ValueChanged onCategoryChanged; + final ValueChanged onRarityChanged; + final ValueChanged onStatTrakChanged; + final ValueChanged onSortChanged; + final ValueChanged onItemTap; + + const _InventoryTab({ + required this.summaries, + required this.totalCount, + required this.search, + required this.selectedCategory, + required this.selectedRarity, + required this.selectedStatTrak, + required this.selectedSort, + required this.onSearchChanged, + required this.onCategoryChanged, + required this.onRarityChanged, + required this.onStatTrakChanged, + required this.onSortChanged, + required this.onItemTap, + }); + + @override + Widget build(BuildContext context) { + final categoryValues = + summaries.map((item) => item.filterCategory).toSet().toList()..sort(); + final rarityValues = summaries.map((item) => item.rarity).toSet().toList() + ..sort(); + + final categoryOptions = ['ALL', ...categoryValues]; + final rarityOptions = ['ALL', ...rarityValues]; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 720; + final fieldWidth = wide + ? (constraints.maxWidth - 24) / 3 + : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + initialValue: search, + decoration: const InputDecoration( + hintText: 'Search your collection...', + prefixIcon: Icon(Icons.search), + isDense: true, + ), + onChanged: onSearchChanged, + ), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + SizedBox( + width: fieldWidth, + child: DropdownButtonFormField( + initialValue: selectedCategory, + decoration: const InputDecoration( + labelText: 'Category', + isDense: true, + ), + items: categoryOptions + .map( + (option) => DropdownMenuItem( + value: option, + child: Text( + option == 'ALL' + ? 'All' + : _categoryLabelFor(option), + ), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + onCategoryChanged(value); + } + }, + ), + ), + SizedBox( + width: fieldWidth, + child: DropdownButtonFormField( + initialValue: selectedRarity, + decoration: const InputDecoration( + labelText: 'Rarity', + isDense: true, + ), + items: rarityOptions + .map( + (option) => DropdownMenuItem( + value: option, + child: Text( + option == 'ALL' + ? 'All' + : _rarityLabel(option), + ), + ), + ) + .toList(), + onChanged: (value) { + if (value != null) { + onRarityChanged(value); + } + }, + ), + ), + SizedBox( + width: fieldWidth, + child: DropdownButtonFormField( + initialValue: selectedStatTrak, + decoration: const InputDecoration( + labelText: 'StatTrak', + isDense: true, + ), + items: const [ + DropdownMenuItem( + value: 'ALL', + child: Text('All'), + ), + DropdownMenuItem( + value: 'ONLY', + child: Text('Only StatTrak'), + ), + DropdownMenuItem( + value: 'NONE', + child: Text('No StatTrak'), + ), + ], + onChanged: (value) { + if (value != null) { + onStatTrakChanged(value); + } + }, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: selectedSort, + decoration: const InputDecoration( + labelText: 'Sort by', + isDense: true, + ), + items: const [ + DropdownMenuItem( + value: 'LATEST', + child: Text('Latest'), + ), + DropdownMenuItem( + value: 'MOST_OWNED', + child: Text('Most collected'), + ), + DropdownMenuItem( + value: 'BEST_FLOAT', + child: Text('Best float'), + ), + DropdownMenuItem( + value: 'A_Z', + child: Text('Name A-Z'), + ), + ], + onChanged: (value) { + if (value != null) { + onSortChanged(value); + } + }, + ), + ), + const SizedBox(width: 12), + Text( + '${summaries.length} / $totalCount', + style: const TextStyle( + color: Colors.white54, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ); + }, + ), + ), + ), + ), + Expanded( + child: summaries.isEmpty + ? const _EmptyCollectionState( + icon: Icons.inventory_2_outlined, + title: 'No items match these filters', + subtitle: + 'Try a broader search or clear one of the active filters.', + ) + : ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: summaries.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) => _InventoryCard( + summary: summaries[index], + onTap: () => onItemTap(summaries[index]), + ), + ), + ), + ], + ); + } +} + +class _InventoryCard extends StatelessWidget { + final CollectionSummary summary; + final VoidCallback onTap; + + const _InventoryCard({required this.summary, required this.onTap}); + + @override + Widget build(BuildContext context) { + final accent = _rarityColor(summary.rarity); + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 88, + height: 72, + alignment: Alignment.center, + child: Image.asset( + summary.imagePath, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => + const Icon(Icons.image_not_supported), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + summary.title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + summary.subtitle, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MetaBadge( + label: '${summary.count} collected', + color: accent, + ), + _MetaBadge(label: summary.categoryLabel), + if (summary.bestFloat != null) + _MetaBadge( + label: + 'Best FV ${summary.bestFloat!.toStringAsFixed(5)}', + ), + if (summary.hasStatTrak) + const _MetaBadge(label: 'StatTrak'), + if (summary.hasSouvenir) + const _MetaBadge(label: 'Souvenir'), + ], + ), + const SizedBox(height: 8), + Text( + 'Latest: ${summary.latestEntry.sourceName} - ${_formatDateTime(summary.latestAcquiredAt)}', + style: const TextStyle( + color: Colors.white54, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Colors.white38), + ], + ), + ), + ), + ); + } +} + +class _HistoryTab extends StatelessWidget { + final List entries; + final ValueChanged onItemTap; + + const _HistoryTab({required this.entries, required this.onItemTap}); + + @override + Widget build(BuildContext context) { + if (entries.isEmpty) { + return const _EmptyCollectionState( + icon: Icons.history, + title: 'No history yet', + subtitle: 'Your opening and Trade-Up results will show up here.', + ); + } + + return ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: entries.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, index) => _HistoryCard( + entry: entries[index], + onTap: () => onItemTap(entries[index]), + ), + ); + } +} + +class _HistoryCard extends StatelessWidget { + final CollectionEntry entry; + final VoidCallback onTap; + + const _HistoryCard({required this.entry, required this.onTap}); + + @override + Widget build(BuildContext context) { + final accent = _rarityColor(entry.rarity); + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 76, + height: 60, + alignment: Alignment.center, + child: Image.asset( + entry.imagePath, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => + const Icon(Icons.image_not_supported), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + entry.subtitle, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MetaBadge(label: entry.categoryLabel, color: accent), + if ((entry.exterior ?? '').isNotEmpty) + _MetaBadge(label: entry.exterior!), + if (entry.floatValue != null) + _MetaBadge( + label: 'FV ${entry.floatValue!.toStringAsFixed(5)}', + ), + if (entry.patternSeed != null) + _MetaBadge(label: 'Pattern ${entry.patternSeed}'), + ], + ), + const SizedBox(height: 8), + Text( + '${entry.sourceName} - ${_formatDateTime(entry.acquiredAt)}', + style: const TextStyle( + color: Colors.white54, + fontSize: 12, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, color: Colors.white38), + ], + ), + ), + ), + ); + } +} + +class _MetaBadge extends StatelessWidget { + final String label; + final Color? color; + + const _MetaBadge({required this.label, this.color}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: (color ?? Colors.white24).withValues(alpha: 0.5), + ), + color: (color ?? Colors.white).withValues(alpha: 0.08), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: color ?? Colors.white70, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _EmptyCollectionState extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + + const _EmptyCollectionState({ + required this.icon, + required this.title, + required this.subtitle, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 42, color: Colors.white38), + const SizedBox(height: 12), + Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + subtitle, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70), + ), + ], + ), + ), + ); + } +} + +class _CollectionData { + final List entries; + final List summaries; + final List<_CollectionProgressItem> progress; + + const _CollectionData({ + required this.entries, + required this.summaries, + required this.progress, + }); +} + +class _CollectionProgressItem { + final String key; + final String label; + final IconData icon; + final int collected; + final int total; + + const _CollectionProgressItem({ + required this.key, + required this.label, + required this.icon, + required this.collected, + required this.total, + }); +} + +Color _rarityColor(String rarity) { + switch (rarity) { + case 'CONSUMER': + return Colors.grey; + case 'INDUSTRIAL': + return Colors.lightBlueAccent; + case 'MIL_SPEC': + return Colors.blue; + case 'RESTRICTED': + return Colors.purpleAccent; + case 'CLASSIFIED': + return Colors.pinkAccent; + case 'COVERT': + return Colors.redAccent; + case 'CONTRABAND': + return const Color(0xFFFF8A00); + case 'HIGH_GRADE': + return Colors.blueAccent; + case 'REMARKABLE': + return Colors.purpleAccent; + case 'EXOTIC': + return Colors.pinkAccent; + case 'EXTRAORDINARY': + return const Color(0xFFEB4B4B); + default: + return Colors.white70; + } +} + +String _formatDateTime(DateTime value) { + final month = value.month.toString().padLeft(2, '0'); + final day = value.day.toString().padLeft(2, '0'); + final hour = value.hour.toString().padLeft(2, '0'); + final minute = value.minute.toString().padLeft(2, '0'); + return '${value.year}-$month-$day $hour:$minute'; +} + +extension on CollectionEntry { + String get categoryLabel => _categoryLabelFor(category); +} + +extension on CollectionSummary { + String get categoryLabel => _categoryLabelFor(filterCategory); +} + +String _categoryLabelFor(String category) { + switch (category) { + case 'knife': + return 'Knife'; + case 'gloves': + return 'Gloves'; + case 'skin': + return 'Skin'; + case 'sticker': + return 'Sticker'; + case 'pin': + return 'Pin'; + case 'music_kit': + return 'Music Kit'; + case 'agent': + return 'Agent'; + case 'graffiti': + return 'Graffiti'; + case 'patch': + return 'Patch'; + case 'charm': + return 'Charm'; + default: + return category; + } +} + +String _rarityLabel(String rarity) { + switch (rarity) { + case 'CONSUMER': + return 'Consumer'; + case 'INDUSTRIAL': + return 'Industrial'; + case 'MIL_SPEC': + return 'Mil-Spec'; + case 'RESTRICTED': + return 'Restricted'; + case 'CLASSIFIED': + return 'Classified'; + case 'COVERT': + return 'Covert'; + case 'CONTRABAND': + return 'Contraband'; + case 'HIGH_GRADE': + return 'High Grade'; + case 'REMARKABLE': + return 'Remarkable'; + case 'EXOTIC': + return 'Exotic'; + case 'EXTRAORDINARY': + return 'Extraordinary'; + default: + return rarity; + } +} + +extension on _MyCollectionScreenState { + Future _openSummary(CollectionSummary summary) async { + final screen = await _buildDetailsScreen( + repository: widget.repository, + settingsController: widget.settingsController, + category: summary.category, + itemId: summary.latestEntry.itemId, + ); + + if (!mounted) { + return; + } + + if (screen == null) { + _showMissingItemMessage(); + return; + } + + AppNavigationHelper.pushScreen(context, screen); + } + + void _showMissingItemMessage() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'This item is no longer available in the current glossary data.', + ), + ), + ); + } +} + +Future _buildDetailsScreen({ + required LocalDataRepository repository, + required SettingsController settingsController, + required String category, + required String itemId, +}) async { + switch (category) { + case 'skin': + case 'knife': + case 'gloves': + final items = await repository.loadSkins(); + final skin = _firstWhereOrNull(items, (item) => item.id == itemId); + if (skin == null) { + return null; + } + return SkinDetailsScreen( + repository: repository, + settingsController: settingsController, + skin: skin, + ); + case 'sticker': + final items = await repository.loadStickers(); + final sticker = _firstWhereOrNull(items, (item) => item.id == itemId); + if (sticker == null) { + return null; + } + return StickerDetailsScreen(repository: repository, sticker: sticker); + case 'pin': + final items = await repository.loadPins(); + final pin = _firstWhereOrNull(items, (item) => item.id == itemId); + if (pin == null) { + return null; + } + return PinDetailsScreen(repository: repository, pin: pin); + case 'music_kit': + final items = await repository.loadGroupedMusicKits(); + final group = _firstWhereOrNull( + items, + (item) => item.variants.any((variant) => variant.id == itemId), + ); + if (group == null) { + return null; + } + return MusicKitDetailsScreen( + repository: repository, + musicKitName: group.name, + collection: group.collection, + ); + case 'agent': + final items = await repository.loadAgents(); + final agent = _firstWhereOrNull(items, (item) => item.id == itemId); + if (agent == null) { + return null; + } + return AgentDetailsScreen(repository: repository, agent: agent); + case 'graffiti': + final items = await repository.loadGraffiti(); + final graffiti = _firstWhereOrNull(items, (item) => item.id == itemId); + if (graffiti == null) { + return null; + } + return GraffitiDetailsScreen(repository: repository, graffiti: graffiti); + case 'patch': + final items = await repository.loadPatches(); + final patch = _firstWhereOrNull(items, (item) => item.id == itemId); + if (patch == null) { + return null; + } + return PatchDetailsScreen(repository: repository, patch: patch); + case 'charm': + final items = await repository.loadCharms(); + final charm = _firstWhereOrNull(items, (item) => item.id == itemId); + if (charm == null) { + return null; + } + return CharmDetailsScreen(repository: repository, charm: charm); + default: + return null; + } +} + +T? _firstWhereOrNull(List items, bool Function(T item) test) { + for (final item in items) { + if (test(item)) { + return item; + } + } + return null; +} diff --git a/lib/presentation/screens/operation_collection_list_screen.dart b/lib/presentation/screens/operation_collection_list_screen.dart index e79d4698..cba6a69c 100644 --- a/lib/presentation/screens/operation_collection_list_screen.dart +++ b/lib/presentation/screens/operation_collection_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../data/models/container_dto.dart'; import '../../data/repositories/local_data_repository.dart'; import '../helpers/app_navigation_helper.dart'; @@ -8,6 +9,7 @@ import '../widgets/async_collection_loader.dart'; import '../widgets/chip_badge.dart'; import '../widgets/collection_filter_bar.dart'; import '../widgets/collection_list_card.dart'; +import '../widgets/collection_source_progress_metadata.dart'; import '../widgets/responsive_collection_grid.dart'; import 'operation_collection_open_screen.dart'; @@ -24,6 +26,8 @@ class OperationCollectionListScreen extends StatefulWidget { class _OperationCollectionListScreenState extends State { late Future> _future; + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); static const String _filterAll = 'ALL'; String _selectedFilter = _filterAll; @@ -130,7 +134,13 @@ class _OperationCollectionListScreenState title: collection.name, releaseDate: collection.releaseDate, chips: [ChipBadge(label: collection.sourceLabel, color: color)], - metadata: const [], + metadata: [ + CollectionSourceProgressMetadata( + container: collection, + repository: widget.repository, + trackingService: _collectionTracking, + ), + ], onTap: () { AppNavigationHelper.pushScreen( context, diff --git a/lib/presentation/screens/operation_collection_open_screen.dart b/lib/presentation/screens/operation_collection_open_screen.dart index d0b4d6c8..3754af60 100644 --- a/lib/presentation/screens/operation_collection_open_screen.dart +++ b/lib/presentation/screens/operation_collection_open_screen.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/skin_dto.dart'; @@ -16,6 +17,7 @@ import '../widgets/collectible_open_body.dart'; import '../widgets/collectible_contents_title.dart'; import '../widgets/collectible_grid_sliver.dart'; import '../widgets/collectible_open_header.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/opening_loading_card.dart'; import '../widgets/skin_drop_card.dart'; import '../widgets/skin_grid_tile.dart'; @@ -41,6 +43,8 @@ class _OperationCollectionOpenScreenState late Future> _skinsFuture; final OperationCollectionSimulatorService _simulator = OperationCollectionSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); final Random _random = Random(); DroppedSkin? _dropped; @@ -75,6 +79,11 @@ class _OperationCollectionOpenScreenState onComplete: (drop) { _dropped = drop; _isOpening = false; + _collectionTracking.recordSkinDrop( + drop: drop, + sourceName: widget.collection.name, + sourceType: widget.collection.typeLabel, + ); }, ); } @@ -149,6 +158,14 @@ class _OperationCollectionOpenScreenState color: _operationColor, ), ], + metadata: [ + CollectionSourceStatsWidget( + sourceName: widget.collection.name, + sourceType: widget.collection.typeLabel, + service: _collectionTracking, + totalCount: displayedContents.length, + ), + ], releaseDateText: formattedReleaseDate, description: 'Legacy operation collection opening. No StatTrak, no knives, no gloves.', diff --git a/lib/presentation/screens/patch_collection_list_screen.dart b/lib/presentation/screens/patch_collection_list_screen.dart index 6df4f1af..7b33b6c4 100644 --- a/lib/presentation/screens/patch_collection_list_screen.dart +++ b/lib/presentation/screens/patch_collection_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../data/models/container_dto.dart'; import '../../data/repositories/local_data_repository.dart'; import '../helpers/app_navigation_helper.dart'; @@ -7,6 +8,7 @@ import '../helpers/source_color_helper.dart'; import '../widgets/async_collection_loader.dart'; import '../widgets/chip_badge.dart'; import '../widgets/collection_list_card.dart'; +import '../widgets/collection_source_progress_metadata.dart'; import '../widgets/responsive_collection_grid.dart'; import 'patch_collection_open_screen.dart'; @@ -22,6 +24,8 @@ class PatchCollectionListScreen extends StatefulWidget { class _PatchCollectionListScreenState extends State { late Future> _future; + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); @override void initState() { @@ -57,6 +61,11 @@ class _PatchCollectionListScreenState extends State { ), ), ], + CollectionSourceProgressMetadata( + container: collection, + repository: widget.repository, + trackingService: _collectionTracking, + ), ], onTap: () { AppNavigationHelper.pushScreen( diff --git a/lib/presentation/screens/patch_collection_open_screen.dart b/lib/presentation/screens/patch_collection_open_screen.dart index 034d2354..38996917 100644 --- a/lib/presentation/screens/patch_collection_open_screen.dart +++ b/lib/presentation/screens/patch_collection_open_screen.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/patch_dto.dart'; @@ -15,6 +16,7 @@ import '../widgets/collectible_open_body.dart'; import '../widgets/collectible_contents_title.dart'; import '../widgets/collectible_grid_sliver.dart'; import '../widgets/collectible_open_header.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/opening_loading_card.dart'; import '../widgets/patch_drop_card.dart'; import '../widgets/patch_grid_tile.dart'; @@ -37,6 +39,8 @@ class PatchCollectionOpenScreen extends StatefulWidget { class _PatchCollectionOpenScreenState extends State { late Future> _patchesFuture; final PatchSimulatorService _simulator = PatchSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); final Random _random = Random(); DroppedPatch? _dropped; @@ -65,6 +69,11 @@ class _PatchCollectionOpenScreenState extends State { onComplete: (drop) { _dropped = drop; _isOpening = false; + _collectionTracking.recordPatchDrop( + drop: drop, + sourceName: widget.collection.name, + sourceType: widget.collection.typeLabel, + ); }, ); } @@ -104,6 +113,12 @@ class _PatchCollectionOpenScreenState extends State { ), ], metadata: [ + CollectionSourceStatsWidget( + sourceName: widget.collection.name, + sourceType: widget.collection.typeLabel, + service: _collectionTracking, + totalCount: patches.length, + ), if ((widget.collection.sourceName ?? '').isNotEmpty) ...[ const SizedBox(height: 8), Text( diff --git a/lib/presentation/screens/patch_container_open_screen.dart b/lib/presentation/screens/patch_container_open_screen.dart index 3829541c..dd79a226 100644 --- a/lib/presentation/screens/patch_container_open_screen.dart +++ b/lib/presentation/screens/patch_container_open_screen.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/patch_dto.dart'; @@ -17,6 +18,7 @@ import '../widgets/collectible_contents_title.dart'; import '../widgets/collectible_grid_sliver.dart'; import '../widgets/collectible_open_header.dart'; import '../widgets/collectible_roller_sliver.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/opening_roll_item_card.dart'; import '../widgets/patch_drop_card.dart'; import '../widgets/patch_grid_tile.dart'; @@ -39,6 +41,8 @@ class PatchContainerOpenScreen extends StatefulWidget { class _PatchContainerOpenScreenState extends State { late Future> _patchesFuture; final PatchSimulatorService _simulator = PatchSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); final Random _random = Random(); final ScrollController _rollController = ScrollController(); @@ -81,6 +85,11 @@ class _PatchContainerOpenScreenState extends State { onComplete: (drop) { _dropped = drop; _isOpening = false; + _collectionTracking.recordPatchDrop( + drop: drop, + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + ); }, ); } @@ -152,6 +161,14 @@ class _PatchContainerOpenScreenState extends State { badges: [ ChipBadge(label: widget.containerDto.typeLabel, color: color), ], + metadata: [ + CollectionSourceStatsWidget( + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + service: _collectionTracking, + totalCount: patches.length, + ), + ], releaseDateText: formattedReleaseDate, description: 'Patch packs use roulette opening and are not affected by X-Ray mode.', diff --git a/lib/presentation/screens/patch_details_screen.dart b/lib/presentation/screens/patch_details_screen.dart index 7b50ccb9..6f78c7d1 100644 --- a/lib/presentation/screens/patch_details_screen.dart +++ b/lib/presentation/screens/patch_details_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_summary.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/patch_dto.dart'; @@ -15,6 +17,8 @@ import '../widgets/detail_tag.dart'; class PatchDetailsScreen extends StatelessWidget { final LocalDataRepository repository; final PatchDto patch; + static final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); const PatchDetailsScreen({ super.key, @@ -28,8 +32,8 @@ class PatchDetailsScreen extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(patch.name)), - body: FutureBuilder>( - future: repository.loadContainersForPatch(patch.id), + body: FutureBuilder<_PatchDetailsData>( + future: _loadData(), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); @@ -47,7 +51,7 @@ class PatchDetailsScreen extends StatelessWidget { ); } - final cases = snapshot.data ?? const []; + final data = snapshot.data ?? const _PatchDetailsData(containers: []); return ListView( padding: const EdgeInsets.all(12), @@ -69,6 +73,11 @@ class PatchDetailsScreen extends StatelessWidget { title: 'Rarity', value: PatchUiHelper.rarityLabel(patch), ), + if (data.collectedCount > 0) + DetailInfoRow( + title: 'Collected', + value: '${data.collectedCount}', + ), if ((patch.collection ?? '').isNotEmpty) DetailInfoRow( title: 'Collection', @@ -79,7 +88,7 @@ class PatchDetailsScreen extends StatelessWidget { const SizedBox(height: 12), DetailSourceSection( title: 'Sources', - items: cases, + items: data.containers, emptyText: 'No patch sources found.', itemBuilder: (item) => DetailSourceTile( imagePath: item.containerImage, @@ -105,4 +114,31 @@ class PatchDetailsScreen extends StatelessWidget { ), ); } + + Future<_PatchDetailsData> _loadData() async { + final results = await Future.wait([ + repository.loadContainersForPatch(patch.id), + _collectionTracking.loadSummaries(), + ]); + + final summaries = results[1] as List; + final collectedCount = summaries + .where( + (item) => + item.category == 'patch' && item.latestEntry.itemId == patch.id, + ) + .fold(0, (sum, item) => sum + item.count); + + return _PatchDetailsData( + containers: results[0] as List, + collectedCount: collectedCount, + ); + } +} + +class _PatchDetailsData { + final List containers; + final int collectedCount; + + const _PatchDetailsData({required this.containers, this.collectedCount = 0}); } diff --git a/lib/presentation/screens/patch_glossary_screen.dart b/lib/presentation/screens/patch_glossary_screen.dart index f8fcc4f8..c8704c2e 100644 --- a/lib/presentation/screens/patch_glossary_screen.dart +++ b/lib/presentation/screens/patch_glossary_screen.dart @@ -126,13 +126,18 @@ class _PatchGlossaryScreenState extends State { ], ), ], - itemBuilder: (context, patch) { + collectedCountBuilder: (patch, collectedByItemId) => + collectedByItemId[patch.id] ?? 0, + itemBuilder: (context, patch, collectedCount) { final color = PatchUiHelper.rarityColor(patch); return GlossaryListItem( accentColor: color, imagePath: patch.patchImage, title: patch.name, subtitle: PatchUiHelper.secondaryText(patch), + collectionInfo: collectedCount > 0 + ? 'Collected $collectedCount' + : null, tags: [ DetailTag(text: PatchUiHelper.rarityLabel(patch), color: color), if ((patch.collection ?? '').isNotEmpty) diff --git a/lib/presentation/screens/pin_container_open_screen.dart b/lib/presentation/screens/pin_container_open_screen.dart index 6764c055..16b16e18 100644 --- a/lib/presentation/screens/pin_container_open_screen.dart +++ b/lib/presentation/screens/pin_container_open_screen.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/pin_dto.dart'; @@ -17,6 +18,7 @@ import '../widgets/collectible_contents_title.dart'; import '../widgets/collectible_grid_sliver.dart'; import '../widgets/collectible_open_header.dart'; import '../widgets/collectible_roller_sliver.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/opening_roll_item_card.dart'; import '../widgets/pin_drop_card.dart'; import '../widgets/pin_grid_tile.dart'; @@ -38,6 +40,8 @@ class PinContainerOpenScreen extends StatefulWidget { class _PinContainerOpenScreenState extends State { late Future> _pinsFuture; final PinSimulatorService _simulator = PinSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); final Random _random = Random(); final ScrollController _rollController = ScrollController(); @@ -80,6 +84,11 @@ class _PinContainerOpenScreenState extends State { onComplete: (drop) { _dropped = drop; _isOpening = false; + _collectionTracking.recordPinDrop( + drop: drop, + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + ); }, ); } @@ -159,6 +168,14 @@ class _PinContainerOpenScreenState extends State { badges: [ ChipBadge(label: widget.containerDto.typeLabel, color: color), ], + metadata: [ + CollectionSourceStatsWidget( + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + service: _collectionTracking, + totalCount: pins.length, + ), + ], releaseDateText: formattedReleaseDate, description: 'Pin capsules roll collectible pins only.', buttonLabel: _isOpening ? 'OPENING...' : 'OPEN PIN CAPSULE', diff --git a/lib/presentation/screens/pin_details_screen.dart b/lib/presentation/screens/pin_details_screen.dart index 3c81f57c..0b36a2cb 100644 --- a/lib/presentation/screens/pin_details_screen.dart +++ b/lib/presentation/screens/pin_details_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_summary.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/pin_dto.dart'; @@ -15,6 +17,8 @@ import '../widgets/detail_tag.dart'; class PinDetailsScreen extends StatelessWidget { final LocalDataRepository repository; final PinDto pin; + static final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); const PinDetailsScreen({ super.key, @@ -28,8 +32,8 @@ class PinDetailsScreen extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(pin.name)), - body: FutureBuilder>( - future: repository.loadContainersForPin(pin.id), + body: FutureBuilder<_PinDetailsData>( + future: _loadData(), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); @@ -47,7 +51,7 @@ class PinDetailsScreen extends StatelessWidget { ); } - final cases = snapshot.data ?? const []; + final data = snapshot.data ?? const _PinDetailsData(containers: []); return ListView( padding: const EdgeInsets.all(12), @@ -69,6 +73,11 @@ class PinDetailsScreen extends StatelessWidget { title: 'Rarity', value: PinUiHelper.rarityLabel(pin), ), + if (data.collectedCount > 0) + DetailInfoRow( + title: 'Collected', + value: '${data.collectedCount}', + ), if ((pin.collection ?? '').isNotEmpty) DetailInfoRow(title: 'Collection', value: pin.collection!), ], @@ -76,7 +85,7 @@ class PinDetailsScreen extends StatelessWidget { const SizedBox(height: 12), DetailSourceSection( title: 'Containers', - items: cases, + items: data.containers, emptyText: 'No pin capsule sources found.', itemBuilder: (item) => DetailSourceTile( imagePath: item.containerImage, @@ -102,4 +111,30 @@ class PinDetailsScreen extends StatelessWidget { ), ); } + + Future<_PinDetailsData> _loadData() async { + final results = await Future.wait([ + repository.loadContainersForPin(pin.id), + _collectionTracking.loadSummaries(), + ]); + + final summaries = results[1] as List; + final collectedCount = summaries + .where( + (item) => item.category == 'pin' && item.latestEntry.itemId == pin.id, + ) + .fold(0, (sum, item) => sum + item.count); + + return _PinDetailsData( + containers: results[0] as List, + collectedCount: collectedCount, + ); + } +} + +class _PinDetailsData { + final List containers; + final int collectedCount; + + const _PinDetailsData({required this.containers, this.collectedCount = 0}); } diff --git a/lib/presentation/screens/pin_glossary_screen.dart b/lib/presentation/screens/pin_glossary_screen.dart index 3caa8ea8..030257d2 100644 --- a/lib/presentation/screens/pin_glossary_screen.dart +++ b/lib/presentation/screens/pin_glossary_screen.dart @@ -132,13 +132,18 @@ class _PinGlossaryScreenState extends State { ], ), ], - itemBuilder: (context, pin) { + collectedCountBuilder: (pin, collectedByItemId) => + collectedByItemId[pin.id] ?? 0, + itemBuilder: (context, pin, collectedCount) { final color = PinUiHelper.rarityColor(pin); return GlossaryListItem( accentColor: color, imagePath: pin.pinImage, title: pin.name, subtitle: PinUiHelper.secondaryText(pin), + collectionInfo: collectedCount > 0 + ? 'Collected $collectedCount' + : null, tags: [ DetailTag(text: PinUiHelper.rarityLabel(pin), color: color), if ((pin.collection ?? '').isNotEmpty) diff --git a/lib/presentation/screens/reward_collection_list_screen.dart b/lib/presentation/screens/reward_collection_list_screen.dart index 7556a5e8..09cbf993 100644 --- a/lib/presentation/screens/reward_collection_list_screen.dart +++ b/lib/presentation/screens/reward_collection_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../data/models/container_dto.dart'; import '../../data/repositories/local_data_repository.dart'; import '../helpers/app_navigation_helper.dart'; @@ -8,6 +9,7 @@ import '../widgets/async_collection_loader.dart'; import '../widgets/chip_badge.dart'; import '../widgets/collection_filter_bar.dart'; import '../widgets/collection_list_card.dart'; +import '../widgets/collection_source_progress_metadata.dart'; import '../widgets/responsive_collection_grid.dart'; import 'reward_collection_open_screen.dart'; @@ -24,6 +26,8 @@ class RewardCollectionListScreen extends StatefulWidget { class _RewardCollectionListScreenState extends State { late Future> _future; + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); static const String _filterAll = 'ALL'; static const String _filterOperation = 'OPERATION'; @@ -114,6 +118,11 @@ class _RewardCollectionListScreenState fontWeight: FontWeight.w600, ), ), + CollectionSourceProgressMetadata( + container: collection, + repository: widget.repository, + trackingService: _collectionTracking, + ), ], onTap: () { AppNavigationHelper.pushScreen( diff --git a/lib/presentation/screens/reward_collection_open_screen.dart b/lib/presentation/screens/reward_collection_open_screen.dart index de43b3d7..5b4d63f3 100644 --- a/lib/presentation/screens/reward_collection_open_screen.dart +++ b/lib/presentation/screens/reward_collection_open_screen.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/skin_dto.dart'; @@ -16,6 +17,7 @@ import '../widgets/collectible_open_body.dart'; import '../widgets/collectible_contents_title.dart'; import '../widgets/collectible_grid_sliver.dart'; import '../widgets/collectible_open_header.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/opening_loading_card.dart'; import '../widgets/skin_drop_card.dart'; import '../widgets/skin_grid_tile.dart'; @@ -41,6 +43,8 @@ class _RewardCollectionOpenScreenState late Future> _skinsFuture; final RewardCollectionSimulatorService _simulator = RewardCollectionSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); final Random _random = Random(); DroppedSkin? _dropped; @@ -76,6 +80,11 @@ class _RewardCollectionOpenScreenState onComplete: (drop) { _dropped = drop; _isOpening = false; + _collectionTracking.recordSkinDrop( + drop: drop, + sourceName: widget.collection.name, + sourceType: widget.collection.typeLabel, + ); }, ); } @@ -153,6 +162,12 @@ class _RewardCollectionOpenScreenState ), ], metadata: [ + CollectionSourceStatsWidget( + sourceName: widget.collection.name, + sourceType: widget.collection.typeLabel, + service: _collectionTracking, + totalCount: displayedContents.length, + ), const SizedBox(height: 8), Text( widget.collection.sourceLabel, diff --git a/lib/presentation/screens/skin_details_screen.dart b/lib/presentation/screens/skin_details_screen.dart index bdc6505b..16563200 100644 --- a/lib/presentation/screens/skin_details_screen.dart +++ b/lib/presentation/screens/skin_details_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_summary.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/settings/settings_controller.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; @@ -20,6 +22,8 @@ class SkinDetailsScreen extends StatelessWidget { final LocalDataRepository repository; final SettingsController settingsController; final SkinDto skin; + static final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); const SkinDetailsScreen({ super.key, @@ -68,6 +72,7 @@ class SkinDetailsScreen extends StatelessWidget { rewardCollections: [], operationCollections: [], variants: [], + collectedCount: 0, ); return ListView( @@ -150,6 +155,8 @@ class SkinDetailsScreen extends StatelessWidget { 'Weapon / slot', SkinUiHelper.weaponTypeLabel(skin.weaponType), ), + if (data.collectedCount > 0) + _infoRow('Collected', '${data.collectedCount}'), if ((skin.finishCatalogName ?? '').isNotEmpty) _infoRow( 'Finish catalog', @@ -402,13 +409,26 @@ class SkinDetailsScreen extends StatelessWidget { repository.loadRewardCollectionsForSkin(skin.id), repository.loadOperationCollectionsForSkin(skin.id), repository.loadSkinVariantsForSkin(skin.id), + _collectionTracking.loadSummaries(), ]); + final variants = results[3] as List; + final summaries = results[4] as List; + final variantIds = variants.map((item) => item.id).toSet(); + final collectedCount = summaries + .where( + (item) => + item.category == 'skin' && + variantIds.contains(item.latestEntry.itemId), + ) + .fold(0, (sum, item) => sum + item.count); + return _SkinSourcesData( containers: results[0] as List, rewardCollections: results[1] as List, operationCollections: results[2] as List, - variants: results[3] as List, + variants: variants, + collectedCount: collectedCount, ); } @@ -520,11 +540,13 @@ class _SkinSourcesData { final List rewardCollections; final List operationCollections; final List variants; + final int collectedCount; const _SkinSourcesData({ required this.containers, required this.rewardCollections, required this.operationCollections, required this.variants, + required this.collectedCount, }); } diff --git a/lib/presentation/screens/skin_glossary_screen.dart b/lib/presentation/screens/skin_glossary_screen.dart index 1796f6f6..0dd22acf 100644 --- a/lib/presentation/screens/skin_glossary_screen.dart +++ b/lib/presentation/screens/skin_glossary_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_summary.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/settings/settings_controller.dart'; import '../../data/models/skin_group_dto.dart'; import '../../data/models/skin_dto.dart'; @@ -26,8 +28,10 @@ class SkinGlossaryScreen extends StatefulWidget { } class _SkinGlossaryScreenState extends State { - late final Future> _future; + late final Future<_SkinGlossaryData> _future; final TextEditingController _searchController = TextEditingController(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); String _query = ''; String _rarityFilter = 'ALL'; @@ -63,7 +67,7 @@ class _SkinGlossaryScreenState extends State { @override void initState() { super.initState(); - _future = widget.repository.loadSkinGroups(); + _future = _loadData(); _searchController.addListener(() { setState(() { _query = _searchController.text.trim().toLowerCase(); @@ -71,6 +75,25 @@ class _SkinGlossaryScreenState extends State { }); } + Future<_SkinGlossaryData> _loadData() async { + final results = await Future.wait([ + widget.repository.loadSkinGroups(), + _collectionTracking.loadSummaries(), + ]); + + final summaries = results[1] as List; + final collectedByItemId = {}; + for (final summary in summaries.where((item) => item.category == 'skin')) { + collectedByItemId[summary.latestEntry.itemId] = + (collectedByItemId[summary.latestEntry.itemId] ?? 0) + summary.count; + } + + return _SkinGlossaryData( + groups: results[0] as List, + collectedByItemId: collectedByItemId, + ); + } + @override void dispose() { _searchController.dispose(); @@ -176,7 +199,7 @@ class _SkinGlossaryScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Skin Glossary')), - body: FutureBuilder>( + body: FutureBuilder<_SkinGlossaryData>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { @@ -195,7 +218,10 @@ class _SkinGlossaryScreenState extends State { ); } - final skins = snapshot.data ?? const []; + final data = + snapshot.data ?? + const _SkinGlossaryData(groups: [], collectedByItemId: {}); + final skins = data.groups; final filtered = _applyFilters(skins); return Column( @@ -336,12 +362,20 @@ class _SkinGlossaryScreenState extends State { final group = filtered[index]; final skin = group.primary; final rarityColor = SkinUiHelper.rarityColor(skin); + final collectedCount = group.variants.fold( + 0, + (sum, variant) => + sum + (data.collectedByItemId[variant.id] ?? 0), + ); return GlossaryListItem( accentColor: rarityColor, imagePath: group.skinImage, title: group.itemDisplayName, subtitle: _subtitle(group), + collectionInfo: collectedCount > 0 + ? 'Collected $collectedCount' + : null, tags: [ _pill( SkinUiHelper.rarityLabel(group.primary), @@ -408,6 +442,16 @@ class _SkinGlossaryScreenState extends State { } } +class _SkinGlossaryData { + final List groups; + final Map collectedByItemId; + + const _SkinGlossaryData({ + required this.groups, + required this.collectedByItemId, + }); +} + class _DropdownItem { final String value; final String label; diff --git a/lib/presentation/screens/sticker_collection_list_screen.dart b/lib/presentation/screens/sticker_collection_list_screen.dart index 9f692dfe..ae00bb1f 100644 --- a/lib/presentation/screens/sticker_collection_list_screen.dart +++ b/lib/presentation/screens/sticker_collection_list_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../data/models/container_dto.dart'; import '../../data/repositories/local_data_repository.dart'; import '../helpers/app_navigation_helper.dart'; @@ -8,6 +9,7 @@ import '../widgets/async_collection_loader.dart'; import '../widgets/chip_badge.dart'; import '../widgets/collection_filter_bar.dart'; import '../widgets/collection_list_card.dart'; +import '../widgets/collection_source_progress_metadata.dart'; import '../widgets/responsive_collection_grid.dart'; class StickerCollectionListScreen extends StatefulWidget { @@ -23,6 +25,8 @@ class StickerCollectionListScreen extends StatefulWidget { class _StickerCollectionListScreenState extends State { late Future> _future; + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); static const String _filterAll = 'ALL'; static const String _filterArmory = 'ARMORY_REWARD'; @@ -117,6 +121,11 @@ class _StickerCollectionListScreenState ), ), ], + CollectionSourceProgressMetadata( + container: collection, + repository: widget.repository, + trackingService: _collectionTracking, + ), ], onTap: () { AppNavigationHelper.pushScreen( diff --git a/lib/presentation/screens/sticker_container_open_screen.dart b/lib/presentation/screens/sticker_container_open_screen.dart index f861c523..7a15c178 100644 --- a/lib/presentation/screens/sticker_container_open_screen.dart +++ b/lib/presentation/screens/sticker_container_open_screen.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/sticker_dto.dart'; @@ -17,6 +18,7 @@ import '../widgets/collectible_contents_title.dart'; import '../widgets/collectible_grid_sliver.dart'; import '../widgets/collectible_open_header.dart'; import '../widgets/collectible_roller_sliver.dart'; +import '../widgets/collection_source_stats.dart'; import '../widgets/opening_roll_item_card.dart'; import '../widgets/opening_loading_card.dart'; import '../widgets/sticker_drop_card.dart'; @@ -41,6 +43,8 @@ class _StickerContainerOpenScreenState extends State { late Future> _stickersFuture; final StickerSimulatorService _simulator = StickerSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); final Random _random = Random(); final ScrollController _rollController = ScrollController(); @@ -85,6 +89,11 @@ class _StickerContainerOpenScreenState onComplete: (drop) { _dropped = drop; _isOpening = false; + _collectionTracking.recordStickerDrop( + drop: drop, + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + ); }, ); return; @@ -107,6 +116,11 @@ class _StickerContainerOpenScreenState onComplete: (drop) { _dropped = drop; _isOpening = false; + _collectionTracking.recordStickerDrop( + drop: drop, + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + ); }, ); } @@ -228,6 +242,14 @@ class _StickerContainerOpenScreenState color: sourceColor, ), ], + metadata: [ + CollectionSourceStatsWidget( + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + service: _collectionTracking, + totalCount: stickers.length, + ), + ], releaseDateText: formattedReleaseDate, description: 'Sticker containers roll only sticker rarities. No float, StatTrak, souvenir, knives, or gloves.', diff --git a/lib/presentation/screens/sticker_details_screen.dart b/lib/presentation/screens/sticker_details_screen.dart index ed439047..7e4f42be 100644 --- a/lib/presentation/screens/sticker_details_screen.dart +++ b/lib/presentation/screens/sticker_details_screen.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_summary.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/sticker_dto.dart'; @@ -15,6 +17,8 @@ import '../widgets/detail_tag.dart'; class StickerDetailsScreen extends StatelessWidget { final LocalDataRepository repository; final StickerDto sticker; + static final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); const StickerDetailsScreen({ super.key, @@ -28,8 +32,8 @@ class StickerDetailsScreen extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(sticker.name)), - body: FutureBuilder>( - future: repository.loadContainersForSticker(sticker.id), + body: FutureBuilder<_StickerDetailsData>( + future: _loadData(), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); @@ -47,7 +51,8 @@ class StickerDetailsScreen extends StatelessWidget { ); } - final cases = snapshot.data ?? const []; + final data = + snapshot.data ?? const _StickerDetailsData(containers: []); return ListView( padding: const EdgeInsets.all(12), @@ -81,6 +86,11 @@ class StickerDetailsScreen extends StatelessWidget { title: 'Effect', value: StickerUiHelper.effectLabel(sticker.effect), ), + if (data.collectedCount > 0) + DetailInfoRow( + title: 'Collected', + value: '${data.collectedCount}', + ), if ((sticker.collection ?? '').isNotEmpty) DetailInfoRow( title: 'Collection', @@ -96,7 +106,7 @@ class StickerDetailsScreen extends StatelessWidget { const SizedBox(height: 12), DetailSourceSection( title: 'Containers', - items: cases, + items: data.containers, emptyText: 'No sticker container sources found.', itemBuilder: (item) { final subtitleParts = [item.typeLabel]; @@ -128,4 +138,35 @@ class StickerDetailsScreen extends StatelessWidget { ), ); } + + Future<_StickerDetailsData> _loadData() async { + final results = await Future.wait([ + repository.loadContainersForSticker(sticker.id), + _collectionTracking.loadSummaries(), + ]); + + final summaries = results[1] as List; + final collectedCount = summaries + .where( + (item) => + item.category == 'sticker' && + item.latestEntry.itemId == sticker.id, + ) + .fold(0, (sum, item) => sum + item.count); + + return _StickerDetailsData( + containers: results[0] as List, + collectedCount: collectedCount, + ); + } +} + +class _StickerDetailsData { + final List containers; + final int collectedCount; + + const _StickerDetailsData({ + required this.containers, + this.collectedCount = 0, + }); } diff --git a/lib/presentation/screens/sticker_glossary_screen.dart b/lib/presentation/screens/sticker_glossary_screen.dart index 32d51f3a..48de0363 100644 --- a/lib/presentation/screens/sticker_glossary_screen.dart +++ b/lib/presentation/screens/sticker_glossary_screen.dart @@ -134,13 +134,18 @@ class _StickerGlossaryScreenState extends State { ], ), ], - itemBuilder: (context, sticker) { + collectedCountBuilder: (sticker, collectedByItemId) => + collectedByItemId[sticker.id] ?? 0, + itemBuilder: (context, sticker, collectedCount) { final color = StickerUiHelper.rarityColor(sticker); return GlossaryListItem( accentColor: color, imagePath: sticker.stickerImage, title: sticker.name, subtitle: StickerUiHelper.secondaryText(sticker), + collectionInfo: collectedCount > 0 + ? 'Collected $collectedCount' + : null, tags: [ DetailTag(text: StickerUiHelper.rarityLabel(sticker), color: color), DetailTag(text: sticker.stickerTypeLabel), diff --git a/lib/presentation/screens/terminal_open_screen.dart b/lib/presentation/screens/terminal_open_screen.dart index b3c33173..a7210208 100644 --- a/lib/presentation/screens/terminal_open_screen.dart +++ b/lib/presentation/screens/terminal_open_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/skin_dto.dart'; @@ -35,6 +36,8 @@ class TerminalOpenScreen extends StatefulWidget { class _TerminalOpenScreenState extends State { late Future> _skinsFuture; final ContainerSimulatorService _simulator = ContainerSimulatorService(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); List _terminalOffers = const []; int _terminalOfferIndex = 0; @@ -114,6 +117,11 @@ class _TerminalOpenScreenState extends State { patternSeed: offer.patternSeed, ); }); + await _collectionTracking.recordSkinDrop( + drop: _dropped!, + sourceName: widget.containerDto.name, + sourceType: widget.containerDto.typeLabel, + ); } Future _skipTerminalOffer() async { diff --git a/lib/presentation/screens/tradeup_screen.dart b/lib/presentation/screens/tradeup_screen.dart index 255ee773..33eb09dd 100644 --- a/lib/presentation/screens/tradeup_screen.dart +++ b/lib/presentation/screens/tradeup_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_tracking_service.dart'; import '../../data/models/skin_dto.dart'; import '../../data/repositories/local_data_repository.dart'; import '../../domain/dropped_skin.dart'; @@ -27,6 +28,8 @@ class _TradeUpScreenState extends State { late Future<_TradeUpData> _dataFuture; late final TradeUpController _controller; + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); String _search = ''; String? _rarity = 'MIL_SPEC'; @@ -113,6 +116,9 @@ class _TradeUpScreenState extends State { ); setState(() {}); } catch (e) { + if (!mounted) { + return; + } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), ); @@ -264,7 +270,24 @@ class _TradeUpScreenState extends State { setState(() { // Controller already updated its state. }); + if (_controller.result != null) { + await _collectionTracking.recordSkinDrop( + drop: DroppedSkin( + skin: _controller.result!.skin, + isStatTrak: _controller.result!.isStatTrak, + isSouvenir: _controller.result!.isSouvenir, + skinFloat: _controller.result!.floatValue, + exterior: _controller.result!.exterior, + patternSeed: _controller.result!.patternSeed, + ), + sourceName: 'Trade-Up Contract', + sourceType: 'Trade-Up', + ); + } } catch (e) { + if (!mounted) { + return; + } ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), ); diff --git a/lib/presentation/widgets/collection_source_progress_metadata.dart b/lib/presentation/widgets/collection_source_progress_metadata.dart new file mode 100644 index 00000000..899e7251 --- /dev/null +++ b/lib/presentation/widgets/collection_source_progress_metadata.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; + +import '../../core/collection/collection_tracking_service.dart'; +import '../../data/models/container_dto.dart'; +import '../../data/models/skin_dto.dart'; +import '../../data/repositories/local_data_repository.dart'; +import '../../domain/special_item_variant_helper.dart'; +import 'collection_source_stats.dart'; + +class CollectionSourceProgressMetadata extends StatelessWidget { + final ContainerDto container; + final LocalDataRepository repository; + final CollectionTrackingService trackingService; + + const CollectionSourceProgressMetadata({ + super.key, + required this.container, + required this.repository, + required this.trackingService, + }); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _loadTotalCount(), + builder: (context, snapshot) { + final totalCount = snapshot.data; + if (totalCount == null || totalCount <= 0) { + return const SizedBox.shrink(); + } + + return CollectionSourceStatsWidget( + sourceName: container.name, + sourceType: container.typeLabel, + service: trackingService, + totalCount: totalCount, + compact: true, + ); + }, + ); + } + + Future _loadTotalCount() async { + if (container.isAgentCollection) { + final agents = await repository.loadAgentsForCollection(container.id); + return agents.length; + } + + if (container.isCharmCollection) { + final charms = await repository.loadCharmsForContainer(container.id); + return charms.length; + } + + if (container.isStickerCollection || container.isStickerCapsule) { + final stickers = await repository.loadStickersForContainer(container.id); + return stickers.length; + } + + if (container.isPatchCollection || container.isPatchPack) { + final patches = await repository.loadPatchesForContainer(container.id); + return patches.length; + } + + if (container.isPinCapsule) { + final pins = await repository.loadPinsForContainer(container.id); + return pins.length; + } + + if (container.isMusicKitBox) { + final musicKits = await repository.loadMusicKitsForContainer( + container.id, + ); + return musicKits.length; + } + + if (container.isGraffitiBox) { + final graffiti = await repository.loadGraffitiForContainer(container.id); + return graffiti.length; + } + + final skins = await repository.loadSkinsForContainer(container.id); + return _groupedSkinFamilyCount(skins); + } + + int _groupedSkinFamilyCount(List skins) { + final families = >{}; + for (final skin in skins) { + final key = SpecialItemVariantHelper.familyKeyForSkin(skin); + families.putIfAbsent(key, () => []).add(skin); + } + + var count = 0; + for (final family in families.values) { + final shouldGroup = + family.length > 1 && + SpecialItemVariantHelper.hasConfiguredVariantWeights(family); + count += shouldGroup ? 1 : family.length; + } + return count; + } +} diff --git a/lib/presentation/widgets/collection_source_stats.dart b/lib/presentation/widgets/collection_source_stats.dart new file mode 100644 index 00000000..6d7809f6 --- /dev/null +++ b/lib/presentation/widgets/collection_source_stats.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; + +import '../../core/collection/collection_tracking_service.dart'; + +class CollectionSourceStatsWidget extends StatelessWidget { + final String sourceName; + final String sourceType; + final CollectionTrackingService service; + final int? totalCount; + final bool compact; + + const CollectionSourceStatsWidget({ + super.key, + required this.sourceName, + required this.sourceType, + required this.service, + this.totalCount, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _load(), + builder: (context, snapshot) { + final stats = snapshot.data; + if (stats == null || + (stats.openedCount == 0 && stats.collectedUniqueCount == 0)) { + return const SizedBox.shrink(); + } + + if (compact) { + final progressText = totalCount == null + ? 'Collected: ${stats.collectedUniqueCount}' + : 'Progress: ${stats.collectedUniqueCount} / $totalCount'; + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '$progressText | Opened: ${stats.openedCount}', + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.only(top: 10), + child: Wrap( + spacing: 10, + runSpacing: 10, + alignment: WrapAlignment.center, + children: [ + _StatPill( + icon: Icons.local_mall_outlined, + label: 'Opened', + value: '${stats.openedCount}', + ), + _StatPill( + icon: Icons.inventory_2_outlined, + label: 'Collected', + value: '${stats.collectedUniqueCount} unique', + ), + if (totalCount != null) + _StatPill( + icon: Icons.checklist_outlined, + label: 'Progress', + value: '${stats.collectedUniqueCount} / $totalCount', + ), + ], + ), + ); + }, + ); + } + + Future _load() async { + final stats = await service.loadSourceStats( + sourceName: sourceName, + sourceType: sourceType, + ); + return CollectionSourceStatsData( + openedCount: stats.openedCount, + collectedUniqueCount: stats.collectedUniqueCount, + ); + } +} + +class _StatPill extends StatelessWidget { + final IconData icon; + final String label; + final String value; + + const _StatPill({ + required this.icon, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.white10), + color: Colors.white.withValues(alpha: 0.04), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: Colors.white70), + const SizedBox(width: 6), + Text( + '$label: $value', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class CollectionSourceStatsData { + final int openedCount; + final int collectedUniqueCount; + + const CollectionSourceStatsData({ + required this.openedCount, + required this.collectedUniqueCount, + }); +} diff --git a/lib/presentation/widgets/generic_glossary_screen.dart b/lib/presentation/widgets/generic_glossary_screen.dart index 8d02e1d1..0c88baec 100644 --- a/lib/presentation/widgets/generic_glossary_screen.dart +++ b/lib/presentation/widgets/generic_glossary_screen.dart @@ -1,16 +1,22 @@ import 'package:flutter/material.dart'; +import '../../core/collection/collection_summary.dart'; +import '../../core/collection/collection_tracking_service.dart'; + class GenericGlossaryScreen extends StatefulWidget { final String title; final String searchHint; final Future> future; final List Function(List items, String query) filterAndSort; - final Widget Function(BuildContext context, T item) itemBuilder; + final Widget Function(BuildContext context, T item, int collectedCount) + itemBuilder; final String Function(int count) countLabelBuilder; final String emptyMessage; final String errorPrefix; final List Function(BuildContext context, List items)? headerControlsBuilder; + final int Function(T item, Map collectedByItemId)? + collectedCountBuilder; const GenericGlossaryScreen({ super.key, @@ -23,6 +29,7 @@ class GenericGlossaryScreen extends StatefulWidget { required this.emptyMessage, required this.errorPrefix, this.headerControlsBuilder, + this.collectedCountBuilder, }); @override @@ -32,6 +39,8 @@ class GenericGlossaryScreen extends StatefulWidget { class _GenericGlossaryScreenState extends State> { final TextEditingController _searchController = TextEditingController(); + final CollectionTrackingService _collectionTracking = + CollectionTrackingService(); String _query = ''; @override @@ -54,8 +63,8 @@ class _GenericGlossaryScreenState extends State> { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(widget.title)), - body: FutureBuilder>( - future: widget.future, + body: FutureBuilder<_GenericGlossaryData>( + future: _loadData(), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); @@ -73,7 +82,10 @@ class _GenericGlossaryScreenState extends State> { ); } - final items = snapshot.data ?? []; + final data = + snapshot.data ?? + const _GenericGlossaryData(items: [], collectedByItemId: {}); + final items = data.items; final filtered = widget.filterAndSort(items, _query); final headerControls = widget.headerControlsBuilder?.call(context, items) ?? @@ -138,8 +150,20 @@ class _GenericGlossaryScreenState extends State> { padding: const EdgeInsets.all(12), itemCount: filtered.length, separatorBuilder: (_, _) => const SizedBox(height: 10), - itemBuilder: (context, index) => - widget.itemBuilder(context, filtered[index]), + itemBuilder: (context, index) { + final item = filtered[index]; + final collectedCount = + widget.collectedCountBuilder?.call( + item, + data.collectedByItemId, + ) ?? + 0; + return widget.itemBuilder( + context, + item, + collectedCount, + ); + }, ), ), ], @@ -148,4 +172,33 @@ class _GenericGlossaryScreenState extends State> { ), ); } + + Future<_GenericGlossaryData> _loadData() async { + final results = await Future.wait([ + widget.future, + _collectionTracking.loadSummaries(), + ]); + + final summaries = results[1] as List; + final collectedByItemId = {}; + for (final summary in summaries) { + collectedByItemId[summary.latestEntry.itemId] = + (collectedByItemId[summary.latestEntry.itemId] ?? 0) + summary.count; + } + + return _GenericGlossaryData( + items: results[0] as List, + collectedByItemId: collectedByItemId, + ); + } +} + +class _GenericGlossaryData { + final List items; + final Map collectedByItemId; + + const _GenericGlossaryData({ + required this.items, + required this.collectedByItemId, + }); } diff --git a/lib/presentation/widgets/glossary_list_item.dart b/lib/presentation/widgets/glossary_list_item.dart index cbda275e..50b39046 100644 --- a/lib/presentation/widgets/glossary_list_item.dart +++ b/lib/presentation/widgets/glossary_list_item.dart @@ -5,6 +5,7 @@ class GlossaryListItem extends StatelessWidget { final String imagePath; final String title; final String subtitle; + final String? collectionInfo; final List tags; final VoidCallback onTap; @@ -14,6 +15,7 @@ class GlossaryListItem extends StatelessWidget { required this.imagePath, required this.title, required this.subtitle, + this.collectionInfo, required this.tags, required this.onTap, }); @@ -68,6 +70,41 @@ class GlossaryListItem extends StatelessWidget { fontSize: 14, ), ), + if ((collectionInfo ?? '').isNotEmpty) ...[ + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 5, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Colors.white.withValues(alpha: 0.05), + border: Border.all(color: Colors.white10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.inventory_2_outlined, + size: 14, + color: Colors.white60, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + collectionInfo!, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], if (tags.isNotEmpty) ...[ const SizedBox(height: 8), Wrap(spacing: 8, runSpacing: 8, children: tags), diff --git a/pubspec.yaml b/pubspec.yaml index 7e9ae7dd..d9a3292d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: cs2_simulator description: "Counter-Strike 2 case opening and Trade-Up contract creation simulator written in Flutter + Dart." publish_to: 'none' -version: 0.12.0 +version: 0.13.0 environment: sdk: ^3.11.3