diff --git a/README.md b/README.md index f3566da5..9d1481af 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Unless explicitly stated otherwise, the repository license applies to the source - Case opening with roulette animation - Optional X-Ray opening mechanic - Souvenir packages with tournament-based dates +- Skin pattern seed and finish variant support, including phase-aware finishes - Major tournament section covering CS:GO and CS2 eras - Major teams and players browsing - Operation and Armory reward collections @@ -144,17 +145,13 @@ 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 +- expanding the simulation layer beyond basic opening flows with deeper item metadata - improving long-term data quality for tournaments, teams, and players - continuing UI/codebase refactoring to reduce duplicated screen logic -- preparing larger simulation features such as pattern support and collection tracking +- preparing larger progression features such as collection tracking and ownership history ## Roadmap -### v0.12 - -- Skin pattern and finish seed support, including knife phases, gem variants, fade-style finishes, and other pattern-driven outcomes - ### v0.13 - Inventory or item ownership tracking in some form diff --git a/lib/data/models/skin_group_dto.dart b/lib/data/models/skin_group_dto.dart new file mode 100644 index 00000000..d7aff70e --- /dev/null +++ b/lib/data/models/skin_group_dto.dart @@ -0,0 +1,35 @@ +import 'skin_dto.dart'; + +class SkinGroupDto { + final String key; + final SkinDto primary; + final List variants; + + const SkinGroupDto({ + required this.key, + required this.primary, + required this.variants, + }); + + String get id => primary.id; + String get name => primary.name; + String get skinImage => primary.skinImage; + String get itemDisplayName => primary.itemDisplayName; + String get rarity => primary.rarity; + String get weaponType => primary.weaponType; + String get itemKind => primary.itemKind; + String? get collection => primary.collection; + bool get isSpecialItem => primary.isSpecialItem; + bool get hasMultipleVariants => variants.length > 1; + + List get variantLabels { + final labels = {}; + for (final variant in variants) { + final label = variant.displayVariant; + if (label != null && label.isNotEmpty) { + labels.add(label); + } + } + return labels.toList(); + } +} diff --git a/lib/data/repositories/local_data_repository.dart b/lib/data/repositories/local_data_repository.dart index 3058e9a7..6168b473 100644 --- a/lib/data/repositories/local_data_repository.dart +++ b/lib/data/repositories/local_data_repository.dart @@ -21,6 +21,7 @@ import '../models/pin_content_dto.dart'; import '../models/pin_dto.dart'; import '../models/reward_collection_content_dto.dart'; import '../models/skin_dto.dart'; +import '../models/skin_group_dto.dart'; import '../models/sticker_content_dto.dart'; import '../models/sticker_dto.dart'; import '../models/tournament_dto.dart'; diff --git a/lib/data/repositories/local_data_repository_queries.dart b/lib/data/repositories/local_data_repository_queries.dart index 8108b235..ed375d10 100644 --- a/lib/data/repositories/local_data_repository_queries.dart +++ b/lib/data/repositories/local_data_repository_queries.dart @@ -1047,6 +1047,57 @@ mixin _LocalDataRepositoryQueries on _LocalDataRepositoryLoaders { result.sort(_compareCollectibleCollectionAsc); return result; } + + Future> loadSkinGroups() async { + final skins = await loadSkins(); + final grouped = >{}; + + for (final skin in skins) { + grouped.putIfAbsent(_skinGroupKey(skin), () => []).add(skin); + } + + final result = grouped.entries.map((entry) { + final variants = List.from(entry.value) + ..sort(_compareSkinVariantsForGroup); + return SkinGroupDto( + key: entry.key, + primary: variants.first, + variants: variants, + ); + }).toList(); + + result.sort((a, b) { + final specialCompare = (a.isSpecialItem ? 1 : 0).compareTo( + b.isSpecialItem ? 1 : 0, + ); + if (specialCompare != 0) return specialCompare; + + final rarityCompare = _rarityOrder( + a.primary, + ).compareTo(_rarityOrder(b.primary)); + if (rarityCompare != 0) return rarityCompare; + + final weaponCompare = a.itemDisplayName.compareTo(b.itemDisplayName); + if (weaponCompare != 0) return weaponCompare; + + return a.name.compareTo(b.name); + }); + + return result; + } + + Future> loadSkinVariantsForSkin(String skinId) async { + final skins = await loadSkins(); + final selected = skins.firstWhere( + (skin) => skin.id == skinId, + orElse: () => throw StateError('Skin not found: $skinId'), + ); + final groupKey = _skinGroupKey(selected); + final result = + skins.where((skin) => _skinGroupKey(skin) == groupKey).toList() + ..sort(_compareSkinVariantsForGroup); + return result; + } } class _TournamentBuilder { @@ -1226,3 +1277,34 @@ int _placeRank(String? place) { final start = int.tryParse(match.group(1) ?? ''); return start ?? (1 << 20); } + +String _skinGroupKey(SkinDto skin) { + return [ + skin.itemKind, + skin.itemId, + skin.name.trim().toLowerCase(), + (skin.finishCatalogName ?? '').trim().toLowerCase(), + (skin.collection ?? '').trim().toLowerCase(), + ].join('|'); +} + +int _compareSkinVariantsForGroup(SkinDto a, SkinDto b) { + final variantCompare = _skinVariantOrder(a).compareTo(_skinVariantOrder(b)); + if (variantCompare != 0) return variantCompare; + return int.parse(a.id).compareTo(int.parse(b.id)); +} + +int _skinVariantOrder(SkinDto skin) { + return switch ((skin.displayVariant ?? '').trim()) { + '' => 0, + 'Phase 1' => 1, + 'Phase 2' => 2, + 'Phase 3' => 3, + 'Phase 4' => 4, + 'Ruby' => 5, + 'Sapphire' => 6, + 'Black Pearl' => 7, + 'Emerald' => 8, + _ => 100, + }; +} diff --git a/lib/domain/container_simulator_service.dart b/lib/domain/container_simulator_service.dart index b45da633..e0f902c6 100644 --- a/lib/domain/container_simulator_service.dart +++ b/lib/domain/container_simulator_service.dart @@ -6,11 +6,34 @@ import 'case_odds.dart'; import 'dropped_skin.dart'; import 'package_odds.dart'; import 'skin_float_helper.dart'; +import 'skin_pattern_helper.dart'; +import 'special_item_variant_helper.dart'; import 'terminal_offer.dart'; class ContainerSimulatorService { final Random _random = Random(); + int? _generatePatternSeedForSkin(SkinDto skin, List sourcePool) { + if (!SkinPatternHelper.hasExplicitPhaseVariant(skin)) { + return SkinPatternHelper.generateSeed(random: _random, skin: skin); + } + + final family = sourcePool + .where( + (candidate) => + candidate.id == skin.id || + SpecialItemVariantHelper.familyKeyForSkin(candidate) == + SpecialItemVariantHelper.familyKeyForSkin(skin), + ) + .toList(); + + return SkinPatternHelper.generateSeed( + random: _random, + skin: skin, + siblingVariants: family, + ); + } + DroppedSkin openCase({ required List skins, required ContainerDto containerDto, @@ -37,6 +60,7 @@ class ContainerSimulatorService { isSouvenir: false, skinFloat: wear.floatValue, exterior: wear.exterior, + patternSeed: _generatePatternSeedForSkin(guaranteedSkin, skins), ); } @@ -76,6 +100,7 @@ class ContainerSimulatorService { isSouvenir: isSouvenir, skinFloat: value, exterior: exterior, + patternSeed: _generatePatternSeedForSkin(selectedSkin, skins), ); } @@ -113,6 +138,7 @@ class ContainerSimulatorService { isStatTrak: isStatTrak, skinFloat: value, exterior: exterior, + patternSeed: _generatePatternSeedForSkin(skin, skins), offerIndex: index + 1, ); }); @@ -126,7 +152,21 @@ class ContainerSimulatorService { throw Exception('No skins available for rarity: $odds'); } - return filtered[_random.nextInt(filtered.length)]; + if (odds == CaseOdds.specialItem) { + return _selectSpecialItemSkin(filtered); + } + + return _selectWeightedVariantSkin(filtered); + } + + SkinDto _selectSpecialItemSkin(List skins) { + final families = SpecialItemVariantHelper.groupFamilies(skins); + if (families.isEmpty) { + throw Exception('No special items available'); + } + + final family = families[_random.nextInt(families.length)]; + return SpecialItemVariantHelper.rollVariant(_random, family); } SkinDto _selectPackageSkin(List skins) { @@ -152,7 +192,31 @@ class ContainerSimulatorService { return skins[_random.nextInt(skins.length)]; } - return filtered[_random.nextInt(filtered.length)]; + return _selectWeightedVariantSkin(filtered); + } + + SkinDto _selectWeightedVariantSkin(List skins) { + final families = SpecialItemVariantHelper.groupFamilies(skins); + if (families.isEmpty) { + throw Exception('No skins available'); + } + + final hasWeightedFamilies = families.any( + (family) => + family.length > 1 && + SpecialItemVariantHelper.hasConfiguredVariantWeights(family), + ); + + if (!hasWeightedFamilies) { + return skins[_random.nextInt(skins.length)]; + } + + final family = families[_random.nextInt(families.length)]; + if (family.length == 1) { + return family.first; + } + + return SpecialItemVariantHelper.rollVariant(_random, family); } CaseOdds _getRandomCaseOdds() { diff --git a/lib/domain/dropped_skin.dart b/lib/domain/dropped_skin.dart index 44a9e36c..f7c4fc17 100644 --- a/lib/domain/dropped_skin.dart +++ b/lib/domain/dropped_skin.dart @@ -6,6 +6,7 @@ class DroppedSkin { final bool isSouvenir; final double? skinFloat; final String? exterior; + final int? patternSeed; const DroppedSkin({ required this.skin, @@ -13,6 +14,7 @@ class DroppedSkin { required this.isSouvenir, required this.skinFloat, required this.exterior, + required this.patternSeed, }); bool get isVanillaKnife => skin.isKnife && skin.name == 'Vanilla'; diff --git a/lib/domain/operation_collection_simulator_service.dart b/lib/domain/operation_collection_simulator_service.dart index e3fc962f..d802b61f 100644 --- a/lib/domain/operation_collection_simulator_service.dart +++ b/lib/domain/operation_collection_simulator_service.dart @@ -5,6 +5,7 @@ import '../data/models/skin_dto.dart'; import 'dropped_skin.dart'; import 'package_odds.dart'; import 'skin_float_helper.dart'; +import 'skin_pattern_helper.dart'; class OperationCollectionSimulatorService { final Random _random = Random(); @@ -30,6 +31,10 @@ class OperationCollectionSimulatorService { isSouvenir: false, skinFloat: wear.floatValue, exterior: wear.exterior, + patternSeed: SkinPatternHelper.generateSeed( + random: _random, + skin: selectedSkin, + ), ); } diff --git a/lib/domain/reward_collection_simulator_service.dart b/lib/domain/reward_collection_simulator_service.dart index 3ba55aac..e0d0aa83 100644 --- a/lib/domain/reward_collection_simulator_service.dart +++ b/lib/domain/reward_collection_simulator_service.dart @@ -5,6 +5,7 @@ import '../data/models/skin_dto.dart'; import 'dropped_skin.dart'; import 'package_odds.dart'; import 'skin_float_helper.dart'; +import 'skin_pattern_helper.dart'; class RewardCollectionSimulatorService { final Random _random = Random(); @@ -30,6 +31,10 @@ class RewardCollectionSimulatorService { isSouvenir: false, skinFloat: wear.floatValue, exterior: wear.exterior, + patternSeed: SkinPatternHelper.generateSeed( + random: _random, + skin: selectedSkin, + ), ); } diff --git a/lib/domain/skin_pattern_helper.dart b/lib/domain/skin_pattern_helper.dart new file mode 100644 index 00000000..503bc0e9 --- /dev/null +++ b/lib/domain/skin_pattern_helper.dart @@ -0,0 +1,304 @@ +import 'dart:math'; + +import '../data/models/skin_dto.dart'; +import 'special_item_variant_helper.dart'; + +class SkinPatternHelper { + static const _seedDrivenFinishes = { + 'CASE HARDENED', + 'CRIMSON WEB', + 'CROSSFADE', + 'FADE', + 'HEAT TREATED', + 'MARBLE FADE', + }; + static const _phaseSplitFinishes = {'DOPPLER', 'GAMMA DOPPLER'}; + + static int? generateSeed({ + required Random random, + required SkinDto skin, + List? siblingVariants, + }) { + if (!supportsPatternSeed(skin)) { + return null; + } + + if (hasExplicitPhaseVariant(skin) && + siblingVariants != null && + siblingVariants.isNotEmpty && + SpecialItemVariantHelper.hasConfiguredVariantWeights(siblingVariants)) { + return SpecialItemVariantHelper.generateSeedForVariant( + random, + siblingVariants, + skin, + ); + } + + return random.nextInt(1000); + } + + static bool supportsPatternSeed(SkinDto skin) { + final finish = _normalizedFinish(skin); + if (finish == null) { + return skin.isSpecialItem; + } + + return _seedDrivenFinishes.contains(finish) || skin.isSpecialItem; + } + + static bool hasExplicitPhaseVariant(SkinDto skin) { + final finish = _normalizedFinish(skin); + if (finish == null) { + return false; + } + + return _phaseSplitFinishes.contains(finish) && + (skin.phase ?? '').trim().isNotEmpty; + } + + static String? patternFamilyLabel(SkinDto skin) { + final finish = _normalizedFinish(skin); + if (finish == null) { + return null; + } + + return switch (finish) { + 'CASE HARDENED' => 'Case Hardened pattern', + 'CRIMSON WEB' => 'Web pattern', + 'CROSSFADE' || 'FADE' => 'Fade pattern', + 'HEAT TREATED' => 'Heat Treated pattern', + 'MARBLE FADE' => 'Marble Fade pattern', + 'DOPPLER' => 'Doppler phase', + 'GAMMA DOPPLER' => 'Gamma Doppler phase', + _ => null, + }; + } + + static String? describePattern({ + required SkinDto skin, + required int? patternSeed, + }) { + if (hasExplicitPhaseVariant(skin)) { + return null; + } + + if (patternSeed == null) { + return null; + } + + final finish = _normalizedFinish(skin); + if (finish == null) { + return null; + } + + return switch (finish) { + 'CASE HARDENED' => _caseHardenedLabel(skin, patternSeed), + 'HEAT TREATED' => _heatTreatedLabel(patternSeed), + 'CRIMSON WEB' => _crimsonWebLabel(patternSeed), + 'FADE' || 'CROSSFADE' => _fadeLabel(patternSeed), + 'MARBLE FADE' => _marbleFadeLabel(patternSeed), + _ => null, + }; + } + + static String? describePatternMetric({ + required SkinDto skin, + required int? patternSeed, + }) { + if (patternSeed == null || hasExplicitPhaseVariant(skin)) { + return null; + } + + final finish = _normalizedFinish(skin); + if (finish == null) { + return null; + } + + return switch (finish) { + 'CASE HARDENED' => + 'Blue coverage ${_caseHardenedBlueCoverage(skin, patternSeed).toStringAsFixed(1)}%', + 'HEAT TREATED' => + 'Blue coverage ${_heatTreatedBlueCoverage(patternSeed).toStringAsFixed(1)}%', + 'CRIMSON WEB' => + 'Web density ${_crimsonWebDensity(patternSeed).toStringAsFixed(1)}%', + 'FADE' || 'CROSSFADE' => + 'Fade index ${_fadePercent(patternSeed).toStringAsFixed(1)}%', + 'MARBLE FADE' => + 'Blend index ${_fadePercent(patternSeed).toStringAsFixed(1)}%', + _ => null, + }; + } + + static String? patternExplanation(SkinDto skin) { + final finish = _normalizedFinish(skin); + if (finish == null) { + return skin.isSpecialItem + ? 'Special items can carry finish-specific seed details.' + : null; + } + + return switch (finish) { + 'CASE HARDENED' => + 'Pattern seed changes blue coverage and can produce rare Blue Gem-style layouts.', + 'HEAT TREATED' => + 'Pattern seed changes blue coverage, with rare blue-heavy results at the top end.', + 'CRIMSON WEB' => + 'Pattern seed changes how dense and visible the web pattern appears.', + 'FADE' || 'CROSSFADE' => + 'Pattern seed controls fade percentage and how complete the fade coverage looks.', + 'MARBLE FADE' => + 'Pattern seed changes the color blend balance across the finish.', + 'DOPPLER' => + 'This finish uses weighted phase outcomes rather than equal odds for every variant.', + 'GAMMA DOPPLER' => + 'This finish uses weighted phase outcomes, with Emerald kept rarer than standard phases.', + _ => + skin.isSpecialItem + ? 'Special items can carry finish-specific seed details.' + : null, + }; + } + + static List possiblePatternOutcomes(SkinDto skin) { + final finish = _normalizedFinish(skin); + if (finish == null) { + return const []; + } + + return switch (finish) { + 'CASE HARDENED' => const [ + 'Possible outcomes include Blue Gem, blue-heavy, gold-heavy, and purple-heavy patterns.', + 'Displayed pattern detail is driven by estimated blue coverage from the seed.', + ], + 'HEAT TREATED' => const [ + 'Possible outcomes include Blue Gem, blue-heavy, and purple-heavy patterns.', + 'Displayed pattern detail is driven by estimated blue coverage from the seed.', + ], + 'CRIMSON WEB' => const [ + 'Possible outcomes range from sparse to dense web layouts.', + 'Dense web patterns are presented as the rarer end of the finish.', + ], + 'FADE' || 'CROSSFADE' => const [ + 'Possible outcomes vary by fade percentage.', + 'Higher fade values indicate fuller fade coverage across the item.', + ], + 'MARBLE FADE' => const [ + 'Possible outcomes vary by blend balance.', + 'Exact gem-style seed mapping is not simulated separately yet.', + ], + 'DOPPLER' => const [ + 'Possible phases: Phase 1, Phase 2, Phase 3, Phase 4, Ruby, Sapphire, and Black Pearl.', + 'Ruby, Sapphire, and Black Pearl are weighted rarer than the standard phases.', + ], + 'GAMMA DOPPLER' => const [ + 'Possible phases: Phase 1, Phase 2, Phase 3, Phase 4, and Emerald.', + 'Emerald is weighted rarer than the standard phases.', + ], + _ => const [], + }; + } + + static String? _normalizedFinish(SkinDto skin) { + final finish = (skin.finishCatalogName ?? '').trim(); + if (finish.isEmpty) { + return null; + } + return finish.toUpperCase(); + } + + static String _caseHardenedLabel(SkinDto skin, int seed) { + final score = _normalizedHash( + '${skin.itemId}|${skin.name}|case_hardened|$seed', + ); + + if (score >= 0.992) { + return 'Blue Gem'; + } + if (score >= 0.93) { + return 'Blue-heavy pattern'; + } + if (score >= 0.78) { + return 'Gold-heavy pattern'; + } + if (score <= 0.08) { + return 'Purple-heavy pattern'; + } + return 'Seed $seed'; + } + + static double _caseHardenedBlueCoverage(SkinDto skin, int seed) { + return 100 * + _normalizedHash('${skin.itemId}|${skin.name}|case_hardened|$seed'); + } + + static String _heatTreatedLabel(int seed) { + final score = _normalizedHash('heat_treated|$seed'); + if (score >= 0.985) { + return 'Blue Gem'; + } + if (score >= 0.88) { + return 'Blue-heavy pattern'; + } + if (score >= 0.7) { + return 'Purple-heavy pattern'; + } + return 'Seed $seed'; + } + + static double _heatTreatedBlueCoverage(int seed) { + return 100 * _normalizedHash('heat_treated|$seed'); + } + + static String _crimsonWebLabel(int seed) { + final score = _normalizedHash('crimson_web|$seed'); + if (score >= 0.82) { + return 'Dense web pattern'; + } + if (score >= 0.42) { + return 'Standard web pattern'; + } + return 'Sparse web pattern'; + } + + static double _crimsonWebDensity(int seed) { + return 100 * _normalizedHash('crimson_web|$seed'); + } + + static double _fadePercent(int seed) { + return 100 * (1 - (seed / 999)); + } + + static String _fadeLabel(int seed) { + final percent = _fadePercent(seed); + if (percent >= 95) { + return 'Near full fade'; + } + if (percent >= 85) { + return 'High fade'; + } + if (percent >= 70) { + return 'Mid fade'; + } + return 'Low fade'; + } + + static String _marbleFadeLabel(int seed) { + final percent = _fadePercent(seed); + if (percent >= 90) { + return 'Blue-heavy blend'; + } + if (percent >= 72) { + return 'Balanced blend'; + } + return 'Warm-heavy blend'; + } + + static double _normalizedHash(String input) { + var hash = 2166136261; + for (final codeUnit in input.codeUnits) { + hash ^= codeUnit; + hash = (hash * 16777619) & 0x7fffffff; + } + return (hash % 1000000) / 1000000; + } +} diff --git a/lib/domain/special_item_variant_helper.dart b/lib/domain/special_item_variant_helper.dart new file mode 100644 index 00000000..322955bf --- /dev/null +++ b/lib/domain/special_item_variant_helper.dart @@ -0,0 +1,201 @@ +import 'dart:math'; + +import '../data/models/skin_dto.dart'; + +class SpecialItemVariantHelper { + static const _dopplerWeights = { + 'Phase 1': 22.5, + 'Phase 2': 22.5, + 'Phase 3': 22.5, + 'Phase 4': 22.5, + 'Ruby': 4.0, + 'Sapphire': 4.0, + 'Black Pearl': 2.0, + }; + + static const _gammaDopplerWeights = { + 'Phase 1': 23.0, + 'Phase 2': 23.0, + 'Phase 3': 23.0, + 'Phase 4': 23.0, + 'Emerald': 8.0, + }; + + static List> groupFamilies(Iterable skins) { + final grouped = >{}; + for (final skin in skins) { + grouped.putIfAbsent(familyKeyForSkin(skin), () => []).add(skin); + } + + final families = grouped.values + .map((items) => List.from(items)..sort(_compareVariants)) + .toList(); + families.sort((a, b) => _familyKey(a.first).compareTo(_familyKey(b.first))); + return families; + } + + static Map variantProbabilities(List variants) { + if (variants.isEmpty) { + return const {}; + } + + final finish = (variants.first.finishCatalogName ?? '') + .trim() + .toUpperCase(); + final configured = switch (finish) { + 'DOPPLER' => _dopplerWeights, + 'GAMMA DOPPLER' => _gammaDopplerWeights, + _ => null, + }; + + if (configured == null) { + final uniform = 1 / variants.length; + return {for (final variant in variants) variant.id: uniform}; + } + + final rawWeights = {}; + for (final variant in variants) { + final label = (variant.displayVariant ?? '').trim(); + rawWeights[variant.id] = configured[label] ?? 0; + } + + final total = rawWeights.values.fold(0, (sum, item) => sum + item); + if (total <= 0) { + final uniform = 1 / variants.length; + return {for (final variant in variants) variant.id: uniform}; + } + + return { + for (final entry in rawWeights.entries) entry.key: entry.value / total, + }; + } + + static SkinDto rollVariant(Random random, List variants) { + final probabilities = variantProbabilities(variants); + if (probabilities.isEmpty) { + return variants.first; + } + + final roll = random.nextDouble(); + double cumulative = 0; + for (final variant in variants) { + cumulative += probabilities[variant.id] ?? 0; + if (roll <= cumulative) { + return variant; + } + } + + return variants.last; + } + + static SkinDto variantForSeed(List variants, int seed) { + if (variants.isEmpty) { + throw ArgumentError('variants must not be empty'); + } + + final buckets = _variantBuckets(variants); + for (final bucket in buckets) { + if (seed >= bucket.start && seed <= bucket.end) { + return bucket.skin; + } + } + + return variants.last; + } + + static int generateSeedForVariant( + Random random, + List variants, + SkinDto selected, + ) { + final buckets = _variantBuckets(variants); + for (final bucket in buckets) { + if (bucket.skin.id == selected.id) { + final width = bucket.end - bucket.start + 1; + if (width <= 0) { + return bucket.start.clamp(0, 999); + } + return bucket.start + random.nextInt(width); + } + } + + return random.nextInt(1000); + } + + static String familyKeyForSkin(SkinDto skin) => _familyKey(skin); + + static bool hasConfiguredVariantWeights(Iterable variants) { + if (variants.isEmpty) { + return false; + } + + final finish = (variants.first.finishCatalogName ?? '') + .trim() + .toUpperCase(); + return finish == 'DOPPLER' || finish == 'GAMMA DOPPLER'; + } + + static String _familyKey(SkinDto skin) { + return [ + skin.itemKind, + skin.itemId, + skin.name.trim().toLowerCase(), + (skin.finishCatalogName ?? '').trim().toLowerCase(), + ].join('|'); + } + + static int _compareVariants(SkinDto a, SkinDto b) { + final phaseCompare = _variantOrder(a).compareTo(_variantOrder(b)); + if (phaseCompare != 0) { + return phaseCompare; + } + return int.parse(a.id).compareTo(int.parse(b.id)); + } + + static int _variantOrder(SkinDto skin) { + return switch ((skin.displayVariant ?? '').trim()) { + '' => 0, + 'Phase 1' => 1, + 'Phase 2' => 2, + 'Phase 3' => 3, + 'Phase 4' => 4, + 'Ruby' => 5, + 'Sapphire' => 6, + 'Black Pearl' => 7, + 'Emerald' => 8, + _ => 100, + }; + } + + static List<_VariantBucket> _variantBuckets(List variants) { + final ordered = List.from(variants)..sort(_compareVariants); + final probabilities = variantProbabilities(ordered); + + var cumulative = 0.0; + final buckets = <_VariantBucket>[]; + + for (var index = 0; index < ordered.length; index++) { + final skin = ordered[index]; + final start = (cumulative * 1000).round(); + cumulative += probabilities[skin.id] ?? 0; + final end = index == ordered.length - 1 + ? 999 + : ((cumulative * 1000).round() - 1).clamp(start, 999); + buckets.add(_VariantBucket(skin: skin, start: start, end: end)); + } + + return buckets; + } +} + +class _VariantBucket { + final SkinDto skin; + final int start; + final int end; + + const _VariantBucket({ + required this.skin, + required this.start, + required this.end, + }); +} diff --git a/lib/domain/terminal_offer.dart b/lib/domain/terminal_offer.dart index 66b29b8c..1d287cc7 100644 --- a/lib/domain/terminal_offer.dart +++ b/lib/domain/terminal_offer.dart @@ -5,6 +5,7 @@ class TerminalOffer { final bool isStatTrak; final double? skinFloat; final String? exterior; + final int? patternSeed; final int offerIndex; // 1..5 const TerminalOffer({ @@ -12,6 +13,7 @@ class TerminalOffer { required this.isStatTrak, required this.skinFloat, required this.exterior, + required this.patternSeed, required this.offerIndex, }); } diff --git a/lib/domain/tradeup_service.dart b/lib/domain/tradeup_service.dart index 60fd40d1..20373c28 100644 --- a/lib/domain/tradeup_service.dart +++ b/lib/domain/tradeup_service.dart @@ -2,6 +2,8 @@ import 'dart:math'; import '../data/models/skin_dto.dart'; import 'skin_float_helper.dart'; +import 'skin_pattern_helper.dart'; +import 'special_item_variant_helper.dart'; enum TradeUpInputQuality { regular, statTrak, souvenir } @@ -38,6 +40,7 @@ class TradeUpResult { final String exterior; final bool isStatTrak; final bool isSouvenir; + final int? patternSeed; const TradeUpResult({ required this.skin, @@ -45,6 +48,7 @@ class TradeUpResult { required this.exterior, required this.isStatTrak, required this.isSouvenir, + required this.patternSeed, }); } @@ -69,6 +73,31 @@ class TradeUpChance { class TradeUpService { final Random _random = Random(); + int? _generatePatternSeedForTradeChance( + TradeUpChance chance, + List allChances, + ) { + if (!SkinPatternHelper.hasExplicitPhaseVariant(chance.skin)) { + return SkinPatternHelper.generateSeed(random: _random, skin: chance.skin); + } + + final familyVariants = allChances + .where( + (entry) => + entry.skin.id == chance.skin.id || + SpecialItemVariantHelper.familyKeyForSkin(entry.skin) == + SpecialItemVariantHelper.familyKeyForSkin(chance.skin), + ) + .map((entry) => entry.skin) + .toList(); + + return SkinPatternHelper.generateSeed( + random: _random, + skin: chance.skin, + siblingVariants: familyVariants, + ); + } + TradeUpResult tradeUp({ required List input, required List allSkins, @@ -174,12 +203,26 @@ class TradeUpService { if (possibleSpecialSkins.isEmpty) continue; - final perSkinProbability = - caseProbability / possibleSpecialSkins.length; - - for (final skin in possibleSpecialSkins) { - skinProbabilityById[skin.id] = - (skinProbabilityById[skin.id] ?? 0) + perSkinProbability; + final families = SpecialItemVariantHelper.groupFamilies( + possibleSpecialSkins, + ); + if (families.isEmpty) continue; + + final familyProbability = caseProbability / families.length; + + for (final family in families) { + final variantWeights = SpecialItemVariantHelper.variantProbabilities( + family, + ); + for (final skin in family) { + final contribution = + familyProbability * (variantWeights[skin.id] ?? 0); + if (contribution <= 0) { + continue; + } + skinProbabilityById[skin.id] = + (skinProbabilityById[skin.id] ?? 0) + contribution; + } } } @@ -215,13 +258,31 @@ class TradeUpService { !s.isSpecialItem; }).toList(); - if (possibleSkins.isEmpty) continue; + if (possibleSkins.isEmpty) { + continue; + } + + final families = SpecialItemVariantHelper.groupFamilies(possibleSkins); + if (families.isEmpty) { + continue; + } + + final familyProbability = collectionProbability / families.length; - final perSkinProbability = collectionProbability / possibleSkins.length; + for (final family in families) { + final variantWeights = SpecialItemVariantHelper.variantProbabilities( + family, + ); - for (final skin in possibleSkins) { - skinProbabilityById[skin.id] = - (skinProbabilityById[skin.id] ?? 0) + perSkinProbability; + for (final skin in family) { + final contribution = + familyProbability * (variantWeights[skin.id] ?? 0); + if (contribution <= 0) { + continue; + } + skinProbabilityById[skin.id] = + (skinProbabilityById[skin.id] ?? 0) + contribution; + } } } @@ -427,6 +488,7 @@ class TradeUpService { exterior: chance.exterior, isStatTrak: chance.isStatTrak, isSouvenir: chance.isSouvenir, + patternSeed: _generatePatternSeedForTradeChance(chance, chances), ); } } @@ -438,6 +500,7 @@ class TradeUpService { exterior: fallback.exterior, isStatTrak: fallback.isStatTrak, isSouvenir: fallback.isSouvenir, + patternSeed: _generatePatternSeedForTradeChance(fallback, chances), ); } diff --git a/lib/presentation/helpers/app_navigation_helper.dart b/lib/presentation/helpers/app_navigation_helper.dart index 883fed21..8a0ba8bf 100644 --- a/lib/presentation/helpers/app_navigation_helper.dart +++ b/lib/presentation/helpers/app_navigation_helper.dart @@ -25,6 +25,13 @@ class AppNavigationHelper { ); } + static Future replaceScreen(BuildContext context, Widget screen) { + return Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => screen), + ); + } + static Widget buildContainerOpenScreen({ required ContainerDto containerDto, required LocalDataRepository repository, diff --git a/lib/presentation/helpers/skin_ui_helper.dart b/lib/presentation/helpers/skin_ui_helper.dart index 2f7770b3..4b386d40 100644 --- a/lib/presentation/helpers/skin_ui_helper.dart +++ b/lib/presentation/helpers/skin_ui_helper.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../data/models/skin_dto.dart'; +import '../../domain/special_item_variant_helper.dart'; class SkinUiHelper { static Color rarityColor(SkinDto skin) { @@ -81,19 +82,58 @@ class SkinUiHelper { static String secondaryText(SkinDto skin) { final variant = skin.displayVariant; if (variant != null && variant.isNotEmpty) { - return '${skin.name} • $variant'; + return '${skin.name} - $variant'; } return skin.name; } + static String familySecondaryText(List variants) { + if (variants.isEmpty) { + return ''; + } + + final primary = variants.first; + final hasWeightedVariants = + variants.length > 1 && + SpecialItemVariantHelper.hasConfiguredVariantWeights(variants); + + if (!hasWeightedVariants) { + return secondaryText(primary); + } + + return primary.name; + } + + static String? familyDetailText(List variants) { + final hasWeightedVariants = + variants.length > 1 && + SpecialItemVariantHelper.hasConfiguredVariantWeights(variants); + if (!hasWeightedVariants) { + return null; + } + + final count = variants.length; + final labels = variants + .map((variant) => variant.displayVariant?.trim()) + .whereType() + .where((label) => label.isNotEmpty) + .toList(); + + if (labels.isEmpty) { + return '$count finish variants'; + } + + return count <= 3 ? labels.join(', ') : '$count finish variants'; + } + static String fullDropDisplayName({ required SkinDto skin, required bool isStatTrak, required bool isSouvenir, }) { - final star = skin.isSpecialItem ? '★ ' : ''; + final star = skin.isSpecialItem ? '\u2605 ' : ''; final souvenirPrefix = isSouvenir ? 'Souvenir ' : ''; - final statTrakPrefix = isStatTrak ? 'StatTrak™ ' : ''; + final statTrakPrefix = isStatTrak ? 'StatTrak\u2122 ' : ''; return '$star$souvenirPrefix$statTrakPrefix${skin.itemDisplayName} | ${skin.name}'; } } diff --git a/lib/presentation/screens/container_open_screen.dart b/lib/presentation/screens/container_open_screen.dart index 9aabd5e6..4a8bbca7 100644 --- a/lib/presentation/screens/container_open_screen.dart +++ b/lib/presentation/screens/container_open_screen.dart @@ -9,6 +9,7 @@ import '../../data/models/skin_dto.dart'; import '../../data/repositories/local_data_repository.dart'; import '../../domain/container_simulator_service.dart'; import '../../domain/dropped_skin.dart'; +import '../../domain/special_item_variant_helper.dart'; import '../helpers/opening_roll_sequence_builder.dart'; import '../helpers/skin_ui_helper.dart'; import '../helpers/source_color_helper.dart'; @@ -347,6 +348,53 @@ class _ContainerOpenScreenState extends State { : 'Open the container to simulate a CS-style drop.'); } + List<_DisplayedContainerSkin> _displayedContents(List skins) { + final families = >{}; + for (final skin in skins) { + final key = SpecialItemVariantHelper.familyKeyForSkin(skin); + families.putIfAbsent(key, () => []).add(skin); + } + + final displayed = <_DisplayedContainerSkin>[]; + final emitted = {}; + + for (final skin in skins) { + final key = SpecialItemVariantHelper.familyKeyForSkin(skin); + if (emitted.contains(key)) { + continue; + } + + final family = families[key] ?? [skin]; + final shouldGroup = + family.length > 1 && + SpecialItemVariantHelper.hasConfiguredVariantWeights(family); + + if (!shouldGroup) { + displayed.add( + _DisplayedContainerSkin( + skin: skin, + family: const [], + secondaryText: SkinUiHelper.secondaryText(skin), + ), + ); + emitted.add(key); + continue; + } + + displayed.add( + _DisplayedContainerSkin( + skin: family.first, + family: family, + secondaryText: SkinUiHelper.familySecondaryText(family), + detailText: SkinUiHelper.familyDetailText(family), + ), + ); + emitted.add(key); + } + + return displayed; + } + @override Widget build(BuildContext context) { final formattedReleaseDate = DateFormatHelper.formatReleaseDate( @@ -361,6 +409,8 @@ class _ContainerOpenScreenState extends State { body: CollectibleOpenBody( future: _skinsFuture, sliverBuilder: (context, constraints, skins, gridCount, aspectRatio) { + final displayedContents = _displayedContents(skins); + return [ SliverToBoxAdapter( child: CollectibleOpenHeader( @@ -405,19 +455,27 @@ class _ContainerOpenScreenState extends State { const SliverToBoxAdapter( child: CollectibleContentsTitle(title: 'Case contents'), ), - CollectibleGridSliver( - items: skins, + CollectibleGridSliver<_DisplayedContainerSkin>( + items: displayedContents, crossAxisCount: gridCount, childAspectRatio: aspectRatio, - itemBuilder: (skin) { + itemBuilder: (entry) { + final droppedId = _dropped?.skin.id; + final pendingId = _pendingXrayDrop?.skin.id; final isDropped = - _dropped?.skin.id == skin.id || - _pendingXrayDrop?.skin.id == skin.id; + droppedId == entry.skin.id || + pendingId == entry.skin.id || + entry.family.any( + (variant) => + variant.id == droppedId || variant.id == pendingId, + ); return SkinGridTile( - skin: skin, + skin: entry.skin, highlighted: isDropped, crossAxisCount: gridCount, + secondaryTextOverride: entry.secondaryText, + detailTextOverride: entry.detailText, ); }, ), @@ -427,3 +485,17 @@ class _ContainerOpenScreenState extends State { ); } } + +class _DisplayedContainerSkin { + final SkinDto skin; + final List family; + final String secondaryText; + final String? detailText; + + const _DisplayedContainerSkin({ + required this.skin, + required this.family, + required this.secondaryText, + this.detailText, + }); +} diff --git a/lib/presentation/screens/home_screen.dart b/lib/presentation/screens/home_screen.dart index fb5a0ee3..4b22e9ec 100644 --- a/lib/presentation/screens/home_screen.dart +++ b/lib/presentation/screens/home_screen.dart @@ -5,16 +5,16 @@ import '../../core/settings/settings_controller.dart'; import '../../data/repositories/local_data_repository.dart'; import '../helpers/app_navigation_helper.dart'; import 'agent_collection_list_screen.dart'; -import 'container_list_screen.dart'; import 'charm_collection_list_screen.dart'; +import 'container_list_screen.dart'; import 'glossary_hub_screen.dart'; import 'operation_collection_list_screen.dart'; import 'patch_collection_list_screen.dart'; import 'player_list_screen.dart'; import 'reward_collection_list_screen.dart'; import 'settings_screen.dart'; -import 'team_list_screen.dart'; import 'sticker_collection_list_screen.dart'; +import 'team_list_screen.dart'; import 'tournament_list_screen.dart'; import 'tradeup_screen.dart'; @@ -30,76 +30,6 @@ class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final menuItems = [ - _HomeMenuItem( - icon: Icons.inventory_2, - title: 'Open Containers', - buildScreen: () => ContainerListScreen( - repository: repository, - settingsController: settingsController, - ), - ), - _HomeMenuItem( - icon: Icons.menu_book, - title: 'Item Glossary', - buildScreen: () => GlossaryHubScreen( - repository: repository, - settingsController: settingsController, - ), - ), - _HomeMenuItem( - icon: Icons.stars, - title: 'Operation / Armory Rewards', - buildScreen: () => RewardCollectionListScreen(repository: repository), - ), - _HomeMenuItem( - icon: Icons.collections_bookmark, - title: 'Legacy Operation Collections', - buildScreen: () => - OperationCollectionListScreen(repository: repository), - ), - _HomeMenuItem( - icon: Icons.badge, - title: 'Agent Collections', - buildScreen: () => AgentCollectionListScreen(repository: repository), - ), - _HomeMenuItem( - icon: Icons.sell, - title: 'Sticker Collections', - buildScreen: () => StickerCollectionListScreen(repository: repository), - ), - _HomeMenuItem( - icon: Icons.style, - title: 'Patch Collections', - buildScreen: () => PatchCollectionListScreen(repository: repository), - ), - _HomeMenuItem( - icon: Icons.key, - title: 'Charm Collections', - buildScreen: () => CharmCollectionListScreen(repository: repository), - ), - _HomeMenuItem( - icon: Icons.emoji_events, - title: 'Majors', - buildScreen: () => TournamentListScreen(repository: repository), - ), - _HomeMenuItem( - icon: Icons.groups_2, - title: 'Major Teams', - buildScreen: () => TeamListScreen(repository: repository), - ), - _HomeMenuItem( - icon: Icons.person_search, - title: 'Major Players', - buildScreen: () => PlayerListScreen(repository: repository), - ), - _HomeMenuItem( - icon: Icons.swap_horiz, - title: 'Trade-Up', - buildScreen: () => TradeUpScreen(repository: repository), - ), - ]; - return Scaffold( appBar: AppBar( title: const Text('CS2 Simulator'), @@ -123,32 +53,177 @@ class HomeScreen extends StatelessWidget { padding: const EdgeInsets.all(16), child: Center( child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: 420, - minHeight: constraints.maxHeight - 32, - ), + constraints: const BoxConstraints(maxWidth: 980), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (int i = 0; i < menuItems.length; i++) ...[ - _menuButton( + _HeroSection( + onOpenContainers: () => _push( + context, + ContainerListScreen( + repository: repository, + settingsController: settingsController, + ), + ), + onGlossary: () => _push( context, - icon: menuItems[i].icon, - title: menuItems[i].title, - onTap: () { - AppNavigationHelper.pushScreen( - context, - menuItems[i].buildScreen(), - ); - }, + GlossaryHubScreen( + repository: repository, + settingsController: settingsController, + ), ), - if (i != menuItems.length - 1) const SizedBox(height: 16), - ], + onTradeUp: () => + _push(context, TradeUpScreen(repository: repository)), + ), + const SizedBox(height: 16), + _ResponsiveSectionGrid( + minChildWidth: 290, + children: [ + _HomeSectionCard( + icon: Icons.emoji_events, + title: 'Majors', + subtitle: + 'Tournament history, teams, players, and linked event items.', + children: [ + _ActionTile( + icon: Icons.emoji_events_outlined, + title: 'Browse Majors', + subtitle: 'CS:GO and CS2 tournament pages', + onTap: () => _push( + context, + TournamentListScreen(repository: repository), + ), + ), + _ActionTile( + icon: Icons.groups_2, + title: 'Major Teams', + subtitle: + 'Organizations, rosters, and placements', + onTap: () => _push( + context, + TeamListScreen(repository: repository), + ), + ), + _ActionTile( + icon: Icons.person_search, + title: 'Major Players', + subtitle: + 'Player histories and autograph context', + onTap: () => _push( + context, + PlayerListScreen(repository: repository), + ), + ), + ], + ), + _HomeSectionCard( + icon: Icons.auto_awesome, + title: 'Collections', + subtitle: + 'Operation rewards, legacy collections, and other collection-style sources.', + children: [ + _CompactActionChip( + icon: Icons.stars, + label: 'Operation / Armory Rewards', + onTap: () => _push( + context, + RewardCollectionListScreen( + repository: repository, + ), + ), + ), + _CompactActionChip( + icon: Icons.collections_bookmark, + label: 'Legacy Operation Collections', + onTap: () => _push( + context, + OperationCollectionListScreen( + repository: repository, + ), + ), + ), + _CompactActionChip( + icon: Icons.badge, + label: 'Agent Collections', + onTap: () => _push( + context, + AgentCollectionListScreen( + repository: repository, + ), + ), + ), + _CompactActionChip( + icon: Icons.sell, + label: 'Sticker Collections', + onTap: () => _push( + context, + StickerCollectionListScreen( + repository: repository, + ), + ), + ), + _CompactActionChip( + icon: Icons.style, + label: 'Patch Collections', + onTap: () => _push( + context, + PatchCollectionListScreen( + repository: repository, + ), + ), + ), + _CompactActionChip( + icon: Icons.key, + label: 'Charm Collections', + onTap: () => _push( + context, + CharmCollectionListScreen( + repository: repository, + ), + ), + ), + ], + ), + _HomeSectionCard( + icon: Icons.tune, + title: 'Explore', + subtitle: + 'Pattern-aware browsing, finish variants, and simulator tools.', + children: [ + _ActionTile( + icon: Icons.menu_book, + title: 'Item Glossary', + subtitle: + 'Browse skins, stickers, agents, music kits, and more', + onTap: () => _push( + context, + GlossaryHubScreen( + repository: repository, + settingsController: settingsController, + ), + ), + ), + _ActionTile( + icon: Icons.swap_horiz, + title: 'Trade-Up Simulator', + subtitle: + 'Preview outcomes, floats, and special-item odds', + onTap: () => _push( + context, + TradeUpScreen(repository: repository), + ), + ), + ], + ), + ], + ), const SizedBox(height: 20), - Text( - appVersion, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).hintColor, + Center( + child: Text( + appVersion, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).hintColor, + ), ), ), ], @@ -161,45 +236,313 @@ class HomeScreen extends StatelessWidget { ); } - Widget _menuButton( - BuildContext context, { - required IconData icon, - required String title, - required VoidCallback onTap, - }) { - return SizedBox( + void _push(BuildContext context, Widget screen) { + AppNavigationHelper.pushScreen(context, screen); + } +} + +class _HeroSection extends StatelessWidget { + final VoidCallback onOpenContainers; + final VoidCallback onGlossary; + final VoidCallback onTradeUp; + + const _HeroSection({ + required this.onOpenContainers, + required this.onGlossary, + required this.onTradeUp, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( width: double.infinity, - height: 64, - child: ElevatedButton( - onPressed: onTap, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon), - const SizedBox(width: 10), - Flexible( - child: Text( - title, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 18), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.colorScheme.primary.withValues(alpha: 0.22), + theme.colorScheme.secondary.withValues(alpha: 0.14), + theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.55), + ], + ), + border: Border.all(color: Colors.white12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Open, inspect, and compare the full CS item ecosystem.', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Containers, trade-ups, pattern-aware skin browsing, and full Major tournament history all live here now.', + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.white70, + height: 1.4, + ), + ), + const SizedBox(height: 18), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _HeroButton( + icon: Icons.inventory_2, + title: 'Open Containers', + onTap: onOpenContainers, + emphasized: true, + ), + _HeroButton( + icon: Icons.menu_book, + title: 'Item Glossary', + onTap: onGlossary, ), + _HeroButton( + icon: Icons.swap_horiz, + title: 'Trade-Up', + onTap: onTradeUp, + ), + ], + ), + ], + ), + ); + } +} + +class _ResponsiveSectionGrid extends StatelessWidget { + final double minChildWidth; + final List children; + + const _ResponsiveSectionGrid({ + required this.minChildWidth, + required this.children, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = (constraints.maxWidth / minChildWidth) + .floor() + .clamp(1, 3); + final itemWidth = + (constraints.maxWidth - ((crossAxisCount - 1) * 16)) / + crossAxisCount; + + return Wrap( + spacing: 16, + runSpacing: 16, + children: [ + for (final child in children) + SizedBox(width: itemWidth, child: child), + ], + ); + }, + ); + } +} + +class _HomeSectionCard extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final List children; + + const _HomeSectionCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.children, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + subtitle, + style: const TextStyle(color: Colors.white70, height: 1.35), ), + const SizedBox(height: 14), + ..._separated(children, const SizedBox(height: 10)), ], ), ), ); } + + List _separated(List items, Widget separator) { + if (items.isEmpty) { + return const []; + } + + final result = []; + for (var i = 0; i < items.length; i++) { + result.add(items[i]); + if (i != items.length - 1) { + result.add(separator); + } + } + return result; + } } -class _HomeMenuItem { +class _ActionTile extends StatelessWidget { final IconData icon; final String title; - final Widget Function() buildScreen; + final String subtitle; + final VoidCallback onTap; - const _HomeMenuItem({ + const _ActionTile({ required this.icon, required this.title, - required this.buildScreen, + required this.subtitle, + required this.onTap, }); + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white10), + color: Colors.white.withValues(alpha: 0.02), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Icon(icon), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 3), + Text( + subtitle, + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + height: 1.35, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right), + ], + ), + ), + ), + ); + } +} + +class _CompactActionChip extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _CompactActionChip({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white10), + color: Colors.white.withValues(alpha: 0.02), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18), + const SizedBox(width: 8), + Flexible( + child: Text(label, style: const TextStyle(fontSize: 13)), + ), + ], + ), + ), + ), + ); + } +} + +class _HeroButton extends StatelessWidget { + final IconData icon; + final String title; + final VoidCallback onTap; + final bool emphasized; + + const _HeroButton({ + required this.icon, + required this.title, + required this.onTap, + this.emphasized = false, + }); + + @override + Widget build(BuildContext context) { + final buttonChild = Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Icon(icon), const SizedBox(width: 8), Text(title)], + ), + ); + + if (emphasized) { + return FilledButton(onPressed: onTap, child: buttonChild); + } + + return OutlinedButton(onPressed: onTap, child: buttonChild); + } } diff --git a/lib/presentation/screens/operation_collection_open_screen.dart b/lib/presentation/screens/operation_collection_open_screen.dart index 8149feb4..d0b4d6c8 100644 --- a/lib/presentation/screens/operation_collection_open_screen.dart +++ b/lib/presentation/screens/operation_collection_open_screen.dart @@ -8,7 +8,9 @@ import '../../data/models/skin_dto.dart'; import '../../data/repositories/local_data_repository.dart'; import '../../domain/dropped_skin.dart'; import '../../domain/operation_collection_simulator_service.dart'; +import '../../domain/special_item_variant_helper.dart'; import '../helpers/collectible_open_flow_helper.dart'; +import '../helpers/skin_ui_helper.dart'; import '../helpers/source_color_helper.dart'; import '../widgets/collectible_open_body.dart'; import '../widgets/collectible_contents_title.dart'; @@ -84,6 +86,45 @@ class _OperationCollectionOpenScreenState return 'OPEN COLLECTION DROP'; } + List<_DisplayedCollectionSkin> _displayedContents(List skins) { + final families = >{}; + for (final skin in skins) { + final key = SpecialItemVariantHelper.familyKeyForSkin(skin); + families.putIfAbsent(key, () => []).add(skin); + } + + final displayed = <_DisplayedCollectionSkin>[]; + final emitted = {}; + + for (final skin in skins) { + final key = SpecialItemVariantHelper.familyKeyForSkin(skin); + if (emitted.contains(key)) { + continue; + } + + final family = families[key] ?? [skin]; + final shouldGroup = + family.length > 1 && + SpecialItemVariantHelper.hasConfiguredVariantWeights(family); + + displayed.add( + _DisplayedCollectionSkin( + skin: family.first, + family: shouldGroup ? family : const [], + secondaryText: shouldGroup + ? SkinUiHelper.familySecondaryText(family) + : SkinUiHelper.secondaryText(skin), + detailText: shouldGroup + ? SkinUiHelper.familyDetailText(family) + : null, + ), + ); + emitted.add(key); + } + + return displayed; + } + @override Widget build(BuildContext context) { final formattedReleaseDate = DateFormatHelper.formatReleaseDate( @@ -95,6 +136,8 @@ class _OperationCollectionOpenScreenState body: CollectibleOpenBody( future: _skinsFuture, sliverBuilder: (context, constraints, skins, gridCount, aspectRatio) { + final displayedContents = _displayedContents(skins); + return [ SliverToBoxAdapter( child: CollectibleOpenHeader( @@ -124,16 +167,21 @@ class _OperationCollectionOpenScreenState const SliverToBoxAdapter( child: CollectibleContentsTitle(title: 'Collection contents'), ), - CollectibleGridSliver( - items: skins, + CollectibleGridSliver<_DisplayedCollectionSkin>( + items: displayedContents, crossAxisCount: gridCount, childAspectRatio: aspectRatio, - itemBuilder: (skin) { - final isDropped = _dropped?.skin.id == skin.id; + itemBuilder: (entry) { + final droppedId = _dropped?.skin.id; + final isDropped = + droppedId == entry.skin.id || + entry.family.any((variant) => variant.id == droppedId); return SkinGridTile( - skin: skin, + skin: entry.skin, highlighted: isDropped, crossAxisCount: gridCount, + secondaryTextOverride: entry.secondaryText, + detailTextOverride: entry.detailText, ); }, ), @@ -143,3 +191,17 @@ class _OperationCollectionOpenScreenState ); } } + +class _DisplayedCollectionSkin { + final SkinDto skin; + final List family; + final String secondaryText; + final String? detailText; + + const _DisplayedCollectionSkin({ + required this.skin, + required this.family, + required this.secondaryText, + this.detailText, + }); +} diff --git a/lib/presentation/screens/player_details_screen.dart b/lib/presentation/screens/player_details_screen.dart index e0cd16c3..21fe846d 100644 --- a/lib/presentation/screens/player_details_screen.dart +++ b/lib/presentation/screens/player_details_screen.dart @@ -7,6 +7,8 @@ import '../../data/repositories/local_data_repository.dart'; import '../helpers/app_navigation_helper.dart'; import '../widgets/adaptive_logo_image.dart'; import '../widgets/async_collection_loader.dart'; +import '../widgets/detail_info_row.dart'; +import '../widgets/major_summary_card.dart'; import 'team_details_screen.dart'; import 'tournament_details_screen.dart'; @@ -67,111 +69,59 @@ class _PlayerDetailsScreenState extends State { return ListView( padding: const EdgeInsets.all(12), children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - _PlayerStickerBadge( - imagePath: latest?.sampleStickerImage, - size: 72, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.playerName, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _PlayerStatChip( - label: '${items.length} Major appearances', - color: Colors.blueAccent, - ), - _PlayerStatChip( - label: '$autographCount autographs', - color: Colors.amber, - ), - if (bestPlace != null) - _PlayerStatChip( - label: 'Best: $bestPlace', - color: Colors.greenAccent, - ), - if (titleCount > 0) - _PlayerStatChip( - label: '$titleCount titles', - color: Colors.pinkAccent, - ), - ], - ), - if (teams.isNotEmpty) ...[ - const SizedBox(height: 10), - Text( - 'Teams: ${teams.join(', ')}', - style: const TextStyle( - color: Colors.white70, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ], - if ((latest?.latestDateText ?? '').isNotEmpty) ...[ - const SizedBox(height: 10), - Text( - 'Latest Major: ${latest!.tournamentName}', - style: const TextStyle( - color: Colors.white70, - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - latest.latestDateText!, - style: const TextStyle( - color: Colors.white60, - fontSize: 12, - ), - ), - if ((latest.teamName ?? '').isNotEmpty) ...[ - const SizedBox(height: 6), - Row( - children: [ - _PlayerTeamLogo( - logoPath: latest.teamLogo, - size: 18, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '${latest.teamName}${(latest.place ?? '').isNotEmpty ? ' • ${latest.place}' : ''}', - style: const TextStyle( - color: Colors.white70, - fontSize: 13, - ), - ), - ), - ], - ), - ], - ], - ], - ), - ), - ], - ), + MajorSummaryCard( + leading: _PlayerStickerBadge( + imagePath: latest?.sampleStickerImage, + size: 92, ), + title: widget.playerName, + subtitle: teams.isEmpty ? null : 'Teams: ${teams.join(', ')}', + tags: [ + _PlayerStatChip( + label: '${items.length} Major appearances', + color: Colors.blueAccent, + ), + _PlayerStatChip( + label: '$autographCount autographs', + color: Colors.amber, + ), + if (bestPlace != null) + _PlayerStatChip( + label: 'Best: $bestPlace', + color: Colors.greenAccent, + ), + if (titleCount > 0) + _PlayerStatChip( + label: '$titleCount titles', + color: Colors.pinkAccent, + ), + ], + infoRows: [ + if ((latest?.tournamentName ?? '').isNotEmpty) + DetailInfoRow( + title: 'Latest Major', + value: latest!.tournamentName, + ), + if ((latest?.latestDateText ?? '').isNotEmpty) + DetailInfoRow( + title: 'Latest Dates', + value: latest!.latestDateText!, + ), + if ((latest?.teamName ?? '').isNotEmpty) + DetailInfoRow( + title: 'Latest Team', + value: + '${latest!.teamName}${(latest.place ?? '').isNotEmpty ? ' - ${latest.place}' : ''}', + ), + ], ), const SizedBox(height: 12), + const MajorSectionHeader( + icon: Icons.timeline_outlined, + title: 'Major Timeline', + subtitle: + 'Tournament results, teams, autograph variants, and career context.', + ), ...items.map( (item) => Padding( padding: const EdgeInsets.only(bottom: 12), diff --git a/lib/presentation/screens/reward_collection_open_screen.dart b/lib/presentation/screens/reward_collection_open_screen.dart index 30243894..de43b3d7 100644 --- a/lib/presentation/screens/reward_collection_open_screen.dart +++ b/lib/presentation/screens/reward_collection_open_screen.dart @@ -8,7 +8,9 @@ import '../../data/models/skin_dto.dart'; import '../../data/repositories/local_data_repository.dart'; import '../../domain/dropped_skin.dart'; import '../../domain/reward_collection_simulator_service.dart'; +import '../../domain/special_item_variant_helper.dart'; import '../helpers/collectible_open_flow_helper.dart'; +import '../helpers/skin_ui_helper.dart'; import '../helpers/source_color_helper.dart'; import '../widgets/collectible_open_body.dart'; import '../widgets/collectible_contents_title.dart'; @@ -85,6 +87,45 @@ class _RewardCollectionOpenScreenState return widget.collection.actionLabel.toUpperCase(); } + List<_DisplayedCollectionSkin> _displayedContents(List skins) { + final families = >{}; + for (final skin in skins) { + final key = SpecialItemVariantHelper.familyKeyForSkin(skin); + families.putIfAbsent(key, () => []).add(skin); + } + + final displayed = <_DisplayedCollectionSkin>[]; + final emitted = {}; + + for (final skin in skins) { + final key = SpecialItemVariantHelper.familyKeyForSkin(skin); + if (emitted.contains(key)) { + continue; + } + + final family = families[key] ?? [skin]; + final shouldGroup = + family.length > 1 && + SpecialItemVariantHelper.hasConfiguredVariantWeights(family); + + displayed.add( + _DisplayedCollectionSkin( + skin: family.first, + family: shouldGroup ? family : const [], + secondaryText: shouldGroup + ? SkinUiHelper.familySecondaryText(family) + : SkinUiHelper.secondaryText(skin), + detailText: shouldGroup + ? SkinUiHelper.familyDetailText(family) + : null, + ), + ); + emitted.add(key); + } + + return displayed; + } + @override Widget build(BuildContext context) { final formattedReleaseDate = DateFormatHelper.formatReleaseDate( @@ -96,6 +137,8 @@ class _RewardCollectionOpenScreenState body: CollectibleOpenBody( future: _skinsFuture, sliverBuilder: (context, constraints, skins, gridCount, aspectRatio) { + final displayedContents = _displayedContents(skins); + return [ SliverToBoxAdapter( child: CollectibleOpenHeader( @@ -147,16 +190,21 @@ class _RewardCollectionOpenScreenState const SliverToBoxAdapter( child: CollectibleContentsTitle(title: 'Collection contents'), ), - CollectibleGridSliver( - items: skins, + CollectibleGridSliver<_DisplayedCollectionSkin>( + items: displayedContents, crossAxisCount: gridCount, childAspectRatio: aspectRatio, - itemBuilder: (skin) { - final isDropped = _dropped?.skin.id == skin.id; + itemBuilder: (entry) { + final droppedId = _dropped?.skin.id; + final isDropped = + droppedId == entry.skin.id || + entry.family.any((variant) => variant.id == droppedId); return SkinGridTile( - skin: skin, + skin: entry.skin, highlighted: isDropped, crossAxisCount: gridCount, + secondaryTextOverride: entry.secondaryText, + detailTextOverride: entry.detailText, ); }, ), @@ -166,3 +214,17 @@ class _RewardCollectionOpenScreenState ); } } + +class _DisplayedCollectionSkin { + final SkinDto skin; + final List family; + final String secondaryText; + final String? detailText; + + const _DisplayedCollectionSkin({ + required this.skin, + required this.family, + required this.secondaryText, + this.detailText, + }); +} diff --git a/lib/presentation/screens/skin_details_screen.dart b/lib/presentation/screens/skin_details_screen.dart index 43e5676e..bdc6505b 100644 --- a/lib/presentation/screens/skin_details_screen.dart +++ b/lib/presentation/screens/skin_details_screen.dart @@ -5,6 +5,7 @@ import '../../core/utils/date_format_helper.dart'; import '../../data/models/container_dto.dart'; import '../../data/models/skin_dto.dart'; import '../../data/repositories/local_data_repository.dart'; +import '../../domain/skin_pattern_helper.dart'; import '../helpers/app_navigation_helper.dart'; import '../helpers/skin_ui_helper.dart'; import '../widgets/detail_info_row.dart'; @@ -30,6 +31,14 @@ class SkinDetailsScreen extends StatelessWidget { @override Widget build(BuildContext context) { final rarityColor = SkinUiHelper.rarityColor(skin); + final patternFamily = SkinPatternHelper.patternFamilyLabel(skin); + final patternExplanation = SkinPatternHelper.patternExplanation(skin); + final patternOutcomes = SkinPatternHelper.possiblePatternOutcomes(skin); + final hasPatternSection = + patternFamily != null || + SkinPatternHelper.supportsPatternSeed(skin) || + patternExplanation != null || + patternOutcomes.isNotEmpty; return Scaffold( appBar: AppBar(title: Text(skin.itemDisplayName)), @@ -58,6 +67,7 @@ class SkinDetailsScreen extends StatelessWidget { containers: [], rewardCollections: [], operationCollections: [], + variants: [], ); return ListView( @@ -151,6 +161,20 @@ class SkinDetailsScreen extends StatelessWidget { _infoRow('Phase', skin.phase!), if ((skin.apiPaintIndex ?? '').isNotEmpty) _infoRow('Paint index', skin.apiPaintIndex!), + if (patternFamily case final value?) + _infoRow('Pattern family', value), + if (SkinPatternHelper.supportsPatternSeed(skin)) + _infoRow('Pattern support', 'Seed-based'), + if (SkinPatternHelper.hasExplicitPhaseVariant(skin)) + _infoRow( + 'Phase logic', + 'Explicit variant with weighted family odds', + ), + if (SkinPatternHelper.supportsPatternSeed(skin)) + _infoRow( + 'Pattern notes', + 'Drops may derive seed-based pattern details', + ), ], ); @@ -174,6 +198,66 @@ class SkinDetailsScreen extends StatelessWidget { ), ), const SizedBox(height: 12), + if (hasPatternSection) ...[ + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Pattern Behavior', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (patternExplanation != null) ...[ + const SizedBox(height: 12), + Text( + patternExplanation, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + ), + ), + ], + if (patternOutcomes.isNotEmpty) ...[ + const SizedBox(height: 14), + for (final outcome in patternOutcomes) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 2), + child: Icon( + Icons.circle, + size: 7, + color: Colors.white54, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + outcome, + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 12), + ], Card( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), @@ -201,6 +285,40 @@ class SkinDetailsScreen extends StatelessWidget { ), ), ), + if (data.variants.length > 1) ...[ + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Finish Variants', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: data.variants + .map( + (variant) => _variantCard( + context, + variant, + selected: variant.id == skin.id, + ), + ) + .toList(), + ), + ], + ), + ), + ), + ], const SizedBox(height: 12), _sourceSection( title: 'Cases / Containers', @@ -283,12 +401,14 @@ class SkinDetailsScreen extends StatelessWidget { repository.loadContainersForSkin(skin.id), repository.loadRewardCollectionsForSkin(skin.id), repository.loadOperationCollectionsForSkin(skin.id), + repository.loadSkinVariantsForSkin(skin.id), ]); return _SkinSourcesData( containers: results[0] as List, rewardCollections: results[1] as List, operationCollections: results[2] as List, + variants: results[3] as List, ); } @@ -330,6 +450,64 @@ class SkinDetailsScreen extends StatelessWidget { ); } + Widget _variantCard( + BuildContext context, + SkinDto variant, { + required bool selected, + }) { + final rarityColor = SkinUiHelper.rarityColor(variant); + + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: selected + ? null + : () { + AppNavigationHelper.replaceScreen( + context, + SkinDetailsScreen( + repository: repository, + settingsController: settingsController, + skin: variant, + ), + ); + }, + child: Container( + width: 144, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected ? rarityColor : Colors.white12, + width: selected ? 2 : 1, + ), + color: selected ? rarityColor.withValues(alpha: 0.1) : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 70, + child: Image.asset( + variant.skinImage, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => + const Icon(Icons.image_not_supported, size: 28), + ), + ), + const SizedBox(height: 8), + Text( + variant.displayVariant ?? variant.name, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + ); + } + String _rewardCollectionSubtitle(ContainerDto item) { final parts = [item.sourceLabel, item.actionLabel]; @@ -341,10 +519,12 @@ class _SkinSourcesData { final List containers; final List rewardCollections; final List operationCollections; + final List variants; const _SkinSourcesData({ required this.containers, required this.rewardCollections, required this.operationCollections, + required this.variants, }); } diff --git a/lib/presentation/screens/skin_glossary_screen.dart b/lib/presentation/screens/skin_glossary_screen.dart index e1325d89..1796f6f6 100644 --- a/lib/presentation/screens/skin_glossary_screen.dart +++ b/lib/presentation/screens/skin_glossary_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import '../../core/settings/settings_controller.dart'; +import '../../data/models/skin_group_dto.dart'; import '../../data/models/skin_dto.dart'; import '../../data/repositories/local_data_repository.dart'; +import '../../domain/skin_pattern_helper.dart'; import '../helpers/app_navigation_helper.dart'; import '../helpers/skin_ui_helper.dart'; import '../widgets/detail_tag.dart'; @@ -24,12 +26,13 @@ class SkinGlossaryScreen extends StatefulWidget { } class _SkinGlossaryScreenState extends State { - late final Future> _future; + late final Future> _future; final TextEditingController _searchController = TextEditingController(); String _query = ''; String _rarityFilter = 'ALL'; String _typeFilter = 'ALL'; + String _patternFilter = 'ALL'; static const List<_DropdownItem> _rarityItems = [ _DropdownItem('ALL', 'All rarities'), @@ -50,10 +53,17 @@ class _SkinGlossaryScreenState extends State { _DropdownItem('GLOVES', 'Gloves'), ]; + static const List<_DropdownItem> _patternItems = [ + _DropdownItem('ALL', 'All finishes'), + _DropdownItem('PATTERN', 'Pattern-sensitive'), + _DropdownItem('SEED', 'Seed-based'), + _DropdownItem('PHASE', 'Phase-based'), + ]; + @override void initState() { super.initState(); - _future = widget.repository.loadSkins(); + _future = widget.repository.loadSkinGroups(); _searchController.addListener(() { setState(() { _query = _searchController.text.trim().toLowerCase(); @@ -67,13 +77,19 @@ class _SkinGlossaryScreenState extends State { super.dispose(); } - List _applyFilters(List skins) { - final filtered = skins.where((skin) { - if (_rarityFilter != 'ALL' && skin.rarity != _rarityFilter) { + List _applyFilters(List skins) { + final filtered = skins.where((group) { + final skin = group.primary; + + if (_rarityFilter != 'ALL' && group.rarity != _rarityFilter) { + return false; + } + + if (_typeFilter != 'ALL' && group.itemKind != _typeFilter) { return false; } - if (_typeFilter != 'ALL' && skin.itemKind != _typeFilter) { + if (!_matchesPatternFilter(skin)) { return false; } @@ -86,6 +102,7 @@ class _SkinGlossaryScreenState extends State { skin.finishCatalogName ?? '', skin.variantName ?? '', skin.phase ?? '', + group.variantLabels.join(' '), skin.rarity, skin.weaponType, skin.itemKind, @@ -100,7 +117,9 @@ class _SkinGlossaryScreenState extends State { ); if (specialCompare != 0) return specialCompare; - final rarityCompare = _rarityOrder(a).compareTo(_rarityOrder(b)); + final rarityCompare = _rarityOrder( + a.primary, + ).compareTo(_rarityOrder(b.primary)); if (rarityCompare != 0) return rarityCompare; final weaponCompare = a.itemDisplayName.compareTo(b.itemDisplayName); @@ -112,6 +131,22 @@ class _SkinGlossaryScreenState extends State { return filtered; } + bool _matchesPatternFilter(SkinDto skin) { + switch (_patternFilter) { + case 'ALL': + return true; + case 'PATTERN': + return SkinPatternHelper.supportsPatternSeed(skin) || + SkinPatternHelper.hasExplicitPhaseVariant(skin); + case 'SEED': + return SkinPatternHelper.supportsPatternSeed(skin); + case 'PHASE': + return SkinPatternHelper.hasExplicitPhaseVariant(skin); + default: + return true; + } + } + int _rarityOrder(SkinDto skin) { if (skin.isSpecialItem) return 999; @@ -141,7 +176,7 @@ class _SkinGlossaryScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Skin Glossary')), - body: FutureBuilder>( + body: FutureBuilder>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { @@ -160,7 +195,7 @@ class _SkinGlossaryScreenState extends State { ); } - final skins = snapshot.data ?? const []; + final skins = snapshot.data ?? const []; final filtered = _applyFilters(skins); return Column( @@ -244,6 +279,29 @@ class _SkinGlossaryScreenState extends State { ], ), const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: _patternFilter, + decoration: InputDecoration( + labelText: 'Pattern behavior', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + items: _patternItems + .map( + (e) => DropdownMenuItem( + value: e.value, + child: Text(e.label), + ), + ) + .toList(), + onChanged: (value) { + setState(() { + _patternFilter = value ?? 'ALL'; + }); + }, + ), + const SizedBox(height: 10), Align( alignment: Alignment.centerLeft, child: Text( @@ -275,30 +333,46 @@ class _SkinGlossaryScreenState extends State { itemCount: filtered.length, separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (context, index) { - final skin = filtered[index]; + final group = filtered[index]; + final skin = group.primary; final rarityColor = SkinUiHelper.rarityColor(skin); return GlossaryListItem( accentColor: rarityColor, - imagePath: skin.skinImage, - title: skin.itemDisplayName, - subtitle: SkinUiHelper.secondaryText(skin), + imagePath: group.skinImage, + title: group.itemDisplayName, + subtitle: _subtitle(group), tags: [ _pill( - SkinUiHelper.rarityLabel(skin), + SkinUiHelper.rarityLabel(group.primary), color: rarityColor, ), _pill( - skin.itemKind == 'WEAPON' + group.itemKind == 'WEAPON' ? SkinUiHelper.weaponTypeLabel( - skin.weaponType, + group.weaponType, ) - : skin.itemKind == 'KNIFE' + : group.itemKind == 'KNIFE' ? 'Knife' : 'Gloves', ), - if ((skin.collection ?? '').isNotEmpty) - _pill(skin.collection!), + if ((group.collection ?? '').isNotEmpty) + _pill(group.collection!), + if (SkinPatternHelper.hasExplicitPhaseVariant( + group.primary, + )) + _pill('Phase-based'), + if (SkinPatternHelper.supportsPatternSeed( + group.primary, + )) + _pill('Seed-based'), + if (SkinPatternHelper.patternFamilyLabel( + group.primary, + ) + case final patternFamily?) + _pill(patternFamily), + if (group.hasMultipleVariants) + _pill('${group.variants.length} variants'), ], onTap: () { AppNavigationHelper.pushScreen( @@ -306,7 +380,7 @@ class _SkinGlossaryScreenState extends State { SkinDetailsScreen( repository: widget.repository, settingsController: widget.settingsController, - skin: skin, + skin: group.primary, ), ); }, @@ -324,6 +398,14 @@ class _SkinGlossaryScreenState extends State { Widget _pill(String text, {Color? color}) { return DetailTag(text: text, color: color); } + + String _subtitle(SkinGroupDto group) { + final labels = group.variantLabels; + if (labels.isNotEmpty) { + return '${group.name} • ${labels.join(', ')}'; + } + return group.name; + } } class _DropdownItem { diff --git a/lib/presentation/screens/team_details_screen.dart b/lib/presentation/screens/team_details_screen.dart index 12285dbe..7ba0f4bc 100644 --- a/lib/presentation/screens/team_details_screen.dart +++ b/lib/presentation/screens/team_details_screen.dart @@ -9,6 +9,7 @@ import '../helpers/app_navigation_helper.dart'; import '../widgets/adaptive_logo_image.dart'; import '../widgets/detail_info_row.dart'; import '../widgets/detail_tag.dart'; +import '../widgets/major_summary_card.dart'; import 'player_details_screen.dart'; import 'tournament_details_screen.dart'; @@ -103,65 +104,38 @@ class _TeamDetailsScreenState extends State { children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), - child: Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _TeamLogoBadge(logoUrl: latest?.teamLogo, size: 72), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - canonicalTeamName, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - DetailTag( - text: '${sorted.length} Major appearances', - ), - if (bestPlace != null) - DetailTag( - text: 'Best: $bestPlace', - color: Colors.amber.shade400, - ), - if (titles > 0) - DetailTag( - text: '$titles Major titles', - color: Colors.greenAccent.shade400, - ), - ], - ), - const SizedBox(height: 14), - DetailInfoRow( - title: 'Latest Major', - value: latest?.tournamentName ?? '-', - ), - DetailInfoRow( - title: 'Latest Dates', - value: - DateFormatHelper.formatDateRange( - latest?.startDate, - latest?.endDate, - ) ?? - '-', - ), - ], - ), - ), - ], + child: MajorSummaryCard( + leading: _TeamLogoBadge(logoUrl: latest?.teamLogo, size: 92), + title: canonicalTeamName, + subtitle: 'Major team history and roster continuity', + tags: [ + DetailTag(text: '${sorted.length} Major appearances'), + if (bestPlace != null) + DetailTag( + text: 'Best: $bestPlace', + color: Colors.amber.shade400, + ), + if (titles > 0) + DetailTag( + text: '$titles Major titles', + color: Colors.greenAccent.shade400, + ), + ], + infoRows: [ + DetailInfoRow( + title: 'Latest Major', + value: latest?.tournamentName ?? '-', ), - ), + DetailInfoRow( + title: 'Latest Dates', + value: + DateFormatHelper.formatDateRange( + latest?.startDate, + latest?.endDate, + ) ?? + '-', + ), + ], ), ), if (recurringPlayers.isNotEmpty) @@ -173,14 +147,12 @@ class _TeamDetailsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Recurring Players', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + const MajorSectionHeader( + icon: Icons.people_alt_outlined, + title: 'Recurring Players', + subtitle: + 'Most common Major players for this organization.', ), - const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, @@ -465,7 +437,17 @@ class _TeamLogoBadge extends StatelessWidget { border: Border.all(color: Colors.white10), ), clipBehavior: Clip.antiAlias, - child: Padding(padding: const EdgeInsets.all(10), child: _buildLogo()), + child: Padding( + padding: const EdgeInsets.all(10), + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(8), + child: _buildLogo(), + ), + ), ); } diff --git a/lib/presentation/screens/team_list_screen.dart b/lib/presentation/screens/team_list_screen.dart index 2ad80ff5..1acf8a69 100644 --- a/lib/presentation/screens/team_list_screen.dart +++ b/lib/presentation/screens/team_list_screen.dart @@ -223,7 +223,17 @@ class _TeamLogoBadge extends StatelessWidget { border: Border.all(color: Colors.white10), ), clipBehavior: Clip.antiAlias, - child: Padding(padding: const EdgeInsets.all(8), child: _buildLogo()), + child: Padding( + padding: const EdgeInsets.all(4), + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(4), + child: _buildLogo(), + ), + ), ); } diff --git a/lib/presentation/screens/terminal_open_screen.dart b/lib/presentation/screens/terminal_open_screen.dart index f812b868..b3c33173 100644 --- a/lib/presentation/screens/terminal_open_screen.dart +++ b/lib/presentation/screens/terminal_open_screen.dart @@ -111,6 +111,7 @@ class _TerminalOpenScreenState extends State { isSouvenir: false, skinFloat: offer.skinFloat, exterior: offer.exterior, + patternSeed: offer.patternSeed, ); }); } diff --git a/lib/presentation/screens/tournament_details_screen.dart b/lib/presentation/screens/tournament_details_screen.dart index dab6eec1..3d928562 100644 --- a/lib/presentation/screens/tournament_details_screen.dart +++ b/lib/presentation/screens/tournament_details_screen.dart @@ -12,6 +12,7 @@ import '../widgets/detail_source_section.dart'; import '../widgets/detail_source_tile.dart'; import '../widgets/detail_tag.dart'; import '../widgets/adaptive_logo_image.dart'; +import '../widgets/major_summary_card.dart'; import 'player_details_screen.dart'; import 'team_details_screen.dart'; @@ -53,114 +54,50 @@ class TournamentDetailsScreen extends StatelessWidget { return ListView( padding: const EdgeInsets.all(12), children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: LayoutBuilder( - builder: (context, constraints) { - final narrow = constraints.maxWidth < 700; - - final image = Container( - alignment: Alignment.center, - child: AdaptiveLogoImage( - logoPath: tournament.imagePath, - height: narrow ? 150 : 200, - fit: BoxFit.contain, - fallback: const Icon(Icons.emoji_events, size: 72), - ), - ); - - final info = Column( - crossAxisAlignment: narrow - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - Text( - tournament.name, - textAlign: narrow - ? TextAlign.center - : TextAlign.left, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 6), - Text( - tournament.organizer, - textAlign: narrow - ? TextAlign.center - : TextAlign.left, - style: const TextStyle( - color: Colors.white70, - fontSize: 16, - ), - ), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - DetailTag( - text: 'Major', - color: Colors.amber.shade400, - ), - DetailTag(text: tournament.eraLabel), - ], - ), - const SizedBox(height: 14), - DetailInfoRow( - title: 'Organizer', - value: tournament.organizer, - ), - if (data.metadata != null) - DetailInfoRow( - title: 'Winner', - value: data.metadata!.winner, - ), - DetailInfoRow( - title: 'Era', - value: tournament.eraLabel, - ), - if (data.metadata?.startDate != null || - data.metadata?.endDate != null) - DetailInfoRow( - title: 'Tournament Dates', - value: - DateFormatHelper.formatDateRange( - data.metadata?.startDate, - data.metadata?.endDate, - ) ?? - '-', - ), - DetailInfoRow( - title: 'Souvenir Packages', - value: data.souvenirPackages.length.toString(), - ), - DetailInfoRow( - title: 'Sticker Sources', - value: data.stickerSources.length.toString(), - ), - ], - ); - - if (narrow) { - return Column( - children: [image, const SizedBox(height: 16), info], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(flex: 4, child: image), - const SizedBox(width: 16), - Expanded(flex: 5, child: info), - ], - ); - }, - ), + MajorSummaryCard( + leading: AdaptiveLogoImage( + logoPath: tournament.imagePath, + height: 170, + fit: BoxFit.contain, + fallback: const Icon(Icons.emoji_events, size: 72), ), + title: tournament.name, + subtitle: tournament.organizer, + tags: [ + DetailTag(text: 'Major', color: Colors.amber.shade400), + DetailTag(text: tournament.eraLabel), + ], + infoRows: [ + DetailInfoRow( + title: 'Organizer', + value: tournament.organizer, + ), + if (data.metadata != null) + DetailInfoRow( + title: 'Winner', + value: data.metadata!.winner, + ), + DetailInfoRow(title: 'Era', value: tournament.eraLabel), + if (data.metadata?.startDate != null || + data.metadata?.endDate != null) + DetailInfoRow( + title: 'Tournament Dates', + value: + DateFormatHelper.formatDateRange( + data.metadata?.startDate, + data.metadata?.endDate, + ) ?? + '-', + ), + DetailInfoRow( + title: 'Souvenir Packages', + value: data.souvenirPackages.length.toString(), + ), + DetailInfoRow( + title: 'Sticker Sources', + value: data.stickerSources.length.toString(), + ), + ], ), const SizedBox(height: 12), if (_hasMeaningfulPlayoffBracket(data.metadata)) ...[ @@ -251,6 +188,11 @@ class TournamentDetailsScreen extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + const MajorSectionHeader( + icon: Icons.format_list_numbered, + title: 'Final Placements', + subtitle: 'Grouped by the tournament stage where teams finished.', + ), for (final phase in orderedPhases) ...[ Padding( padding: const EdgeInsets.only(bottom: 8), @@ -439,34 +381,45 @@ class TournamentDetailsScreen extends StatelessWidget { } final rounds = grouped.keys.toList(); - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final round in rounds) - Container( - width: 260, - margin: EdgeInsets.only(right: round == rounds.last ? 0 : 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - round, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - ...grouped[round]!.map( - (match) => _buildPlayoffMatchCard(context, match, metadata), + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const MajorSectionHeader( + icon: Icons.account_tree_outlined, + title: 'Playoff Bracket', + subtitle: 'Quarterfinals, semifinals, and final results.', + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final round in rounds) + Container( + width: 260, + margin: EdgeInsets.only(right: round == rounds.last ? 0 : 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + round, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + ...grouped[round]!.map( + (match) => + _buildPlayoffMatchCard(context, match, metadata), + ), + ], ), - ], - ), - ), - ], - ), + ), + ], + ), + ), + ], ); } @@ -580,88 +533,109 @@ class TournamentDetailsScreen extends StatelessWidget { } } - showModalBottomSheet( + showDialog( context: context, - isScrollControlled: true, - builder: (sheetContext) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _TeamLogo(logoPath: roster?.teamLogo ?? logoPath, size: 28), - const SizedBox(width: 10), - Expanded( - child: Text( - team, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, + builder: (dialogContext) { + return Dialog( + insetPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 24, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 18, 18, 18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _TeamLogo( + logoPath: roster?.teamLogo ?? logoPath, + size: 30, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + team, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), ), - ), - ], - ), - const SizedBox(height: 14), - if (roster != null && roster.players.isNotEmpty) ...[ - const Text( - 'Tournament roster', - style: TextStyle( - color: Colors.white70, - fontSize: 13, - fontWeight: FontWeight.w600, - ), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.pop(dialogContext), + icon: const Icon(Icons.close), + ), + ], ), const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 8, + const MajorSectionHeader( + icon: Icons.groups_2_outlined, + title: 'Tournament Roster', + subtitle: + 'Open a player page or jump to the full team history.', + ), + if (roster != null && roster.players.isNotEmpty) ...[ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final player in roster.players) + ActionChip( + label: Text(player), + onPressed: () { + Navigator.pop(dialogContext); + AppNavigationHelper.pushScreen( + context, + PlayerDetailsScreen( + playerName: player, + repository: repository, + ), + ); + }, + ), + ], + ), + const SizedBox(height: 16), + ] else ...[ + const Text( + 'No roster data available for this tournament team.', + style: TextStyle(color: Colors.white70), + ), + const SizedBox(height: 16), + ], + Row( children: [ - for (final player in roster.players) - ActionChip( - label: Text(player), + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Close'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.tonal( onPressed: () { - Navigator.pop(sheetContext); + Navigator.pop(dialogContext); AppNavigationHelper.pushScreen( context, - PlayerDetailsScreen( - playerName: player, + TeamDetailsScreen( + teamName: team, repository: repository, ), ); }, + child: const Text('Open team page'), ), + ), ], ), - const SizedBox(height: 16), - ] else ...[ - const Text( - 'No roster data available for this tournament team.', - style: TextStyle(color: Colors.white70), - ), - const SizedBox(height: 16), ], - SizedBox( - width: double.infinity, - child: FilledButton.tonal( - onPressed: () { - Navigator.pop(sheetContext); - AppNavigationHelper.pushScreen( - context, - TeamDetailsScreen( - teamName: team, - repository: repository, - ), - ); - }, - child: const Text('Open team page'), - ), - ), - ], + ), ), ), ); diff --git a/lib/presentation/screens/tradeup_screen.dart b/lib/presentation/screens/tradeup_screen.dart index 6841c371..255ee773 100644 --- a/lib/presentation/screens/tradeup_screen.dart +++ b/lib/presentation/screens/tradeup_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../data/models/skin_dto.dart'; import '../../data/repositories/local_data_repository.dart'; import '../../domain/dropped_skin.dart'; +import '../../domain/special_item_variant_helper.dart'; import '../../domain/tradeup_service.dart'; import '../helpers/responsive_grid_helper.dart'; import '../helpers/tradeup_controller.dart'; @@ -281,6 +282,58 @@ class _TradeUpScreenState extends State { collection.contains(q); } + List<_DisplayedTradeUpChance> _displayedChances(List chances) { + if (chances.isEmpty) { + return const []; + } + + final grouped = >{}; + for (final chance in chances) { + final skin = chance.skin; + final key = (skin.displayVariant ?? '').trim().isNotEmpty + ? SpecialItemVariantHelper.familyKeyForSkin(skin) + : skin.id; + grouped.putIfAbsent(key, () => []).add(chance); + } + + final displayed = grouped.values.map((group) { + group.sort((a, b) => b.probability.compareTo(a.probability)); + final representative = group.first; + final familySkins = group.map((item) => item.skin).toList(); + final shouldGroup = + group.length > 1 && + SpecialItemVariantHelper.hasConfiguredVariantWeights(familySkins); + + if (!shouldGroup) { + return _DisplayedTradeUpChance(chance: representative); + } + + final totalProbability = group.fold( + 0, + (sum, item) => sum + item.probability, + ); + final labels = group + .map((item) => item.skin.displayVariant?.trim()) + .whereType() + .where((value) => value.isNotEmpty) + .toList(); + final detail = labels.isEmpty + ? 'Family total' + : 'Includes ${labels.join(', ')}'; + + return _DisplayedTradeUpChance( + chance: representative, + probabilityOverride: totalProbability, + detailOverride: detail, + ); + }).toList(); + + displayed.sort( + (a, b) => b.displayedProbability.compareTo(a.displayedProbability), + ); + return displayed; + } + String _qualityLabel(TradeUpInputQuality quality) { switch (quality) { case TradeUpInputQuality.regular: @@ -333,6 +386,7 @@ class _TradeUpScreenState extends State { }).toList(); filtered.sort((a, b) => int.parse(a.id).compareTo(int.parse(b.id))); + final displayedChances = _displayedChances(_controller.chances); return LayoutBuilder( builder: (context, constraints) { @@ -526,12 +580,13 @@ class _TradeUpScreenState extends State { isSouvenir: _controller.result!.isSouvenir, skinFloat: _controller.result!.floatValue, exterior: _controller.result!.exterior, + patternSeed: _controller.result!.patternSeed, ), ), ), ), ), - if (_controller.chances.isNotEmpty) + if (displayedChances.isNotEmpty) const SliverToBoxAdapter( child: Padding( padding: EdgeInsets.fromLTRB(12, 12, 12, 8), @@ -544,15 +599,19 @@ class _TradeUpScreenState extends State { ), ), ), - if (_controller.chances.isNotEmpty) + if (displayedChances.isNotEmpty) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 12), sliver: SliverGrid( delegate: SliverChildBuilderDelegate( (_, index) => TradeUpChanceCard( - chance: _controller.chances[index], + chance: displayedChances[index].chance, + probabilityOverride: + displayedChances[index].probabilityOverride, + detailOverride: + displayedChances[index].detailOverride, ), - childCount: _controller.chances.length, + childCount: displayedChances.length, ), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: @@ -623,3 +682,17 @@ class _TradeUpData { required this.regularCaseIdToSkinIds, }); } + +class _DisplayedTradeUpChance { + final TradeUpChance chance; + final double? probabilityOverride; + final String? detailOverride; + + const _DisplayedTradeUpChance({ + required this.chance, + this.probabilityOverride, + this.detailOverride, + }); + + double get displayedProbability => probabilityOverride ?? chance.probability; +} diff --git a/lib/presentation/widgets/major_summary_card.dart b/lib/presentation/widgets/major_summary_card.dart new file mode 100644 index 00000000..02093607 --- /dev/null +++ b/lib/presentation/widgets/major_summary_card.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; + +class MajorSummaryCard extends StatelessWidget { + final Widget leading; + final String title; + final String? subtitle; + final List tags; + final List infoRows; + + const MajorSummaryCard({ + super.key, + required this.leading, + required this.title, + this.subtitle, + this.tags = const [], + this.infoRows = const [], + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: LayoutBuilder( + builder: (context, constraints) { + final narrow = constraints.maxWidth < 700; + + final info = Column( + crossAxisAlignment: narrow + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + Text( + title, + textAlign: narrow ? TextAlign.center : TextAlign.left, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + if ((subtitle ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + subtitle!, + textAlign: narrow ? TextAlign.center : TextAlign.left, + style: const TextStyle(color: Colors.white70, fontSize: 16), + ), + ], + if (tags.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap(spacing: 8, runSpacing: 8, children: tags), + ], + if (infoRows.isNotEmpty) ...[ + const SizedBox(height: 14), + ...infoRows, + ], + ], + ); + + if (narrow) { + return Column( + children: [ + Center(child: leading), + const SizedBox(height: 16), + info, + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 4, child: Center(child: leading)), + const SizedBox(width: 16), + Expanded(flex: 5, child: info), + ], + ); + }, + ), + ), + ); + } +} + +class MajorSectionHeader extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + + const MajorSectionHeader({ + super.key, + required this.icon, + required this.title, + this.subtitle, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + children: [ + Icon(icon, size: 18, color: Colors.amber.shade300), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if ((subtitle ?? '').trim().isNotEmpty) + Text( + subtitle!, + style: const TextStyle(color: Colors.white60, fontSize: 12), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/widgets/skin_drop_card.dart b/lib/presentation/widgets/skin_drop_card.dart index de49513c..23645c2c 100644 --- a/lib/presentation/widgets/skin_drop_card.dart +++ b/lib/presentation/widgets/skin_drop_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../domain/dropped_skin.dart'; +import '../../domain/skin_pattern_helper.dart'; import '../helpers/skin_ui_helper.dart'; import 'info_row.dart'; @@ -13,6 +14,16 @@ class SkinDropCard extends StatelessWidget { Widget build(BuildContext context) { final rarityColor = SkinUiHelper.rarityColor(drop.skin); final variantText = SkinUiHelper.secondaryText(drop.skin); + final patternSummary = SkinPatternHelper.describePattern( + skin: drop.skin, + patternSeed: drop.patternSeed, + ); + final patternMetric = SkinPatternHelper.describePatternMetric( + skin: drop.skin, + patternSeed: drop.patternSeed, + ); + final patternFamily = SkinPatternHelper.patternFamilyLabel(drop.skin); + final phaseText = (drop.skin.phase ?? '').trim(); return Card( margin: const EdgeInsets.all(12), @@ -86,6 +97,23 @@ class SkinDropCard extends StatelessWidget { value: drop.skinFloat?.toStringAsFixed(6) ?? '-', ), InfoRow(title: 'Exterior', value: drop.exterior ?? '-'), + if (phaseText.isNotEmpty) + InfoRow(title: 'Phase', value: phaseText), + if (drop.patternSeed != null) + InfoRow( + title: 'Pattern seed', + value: drop.patternSeed.toString(), + ), + if (patternFamily != null) + InfoRow(title: 'Pattern family', value: patternFamily), + if (patternSummary != null && + patternSummary.trim().isNotEmpty && + patternSummary.trim() != phaseText) + InfoRow(title: 'Pattern', value: patternSummary), + if (patternMetric != null && + patternMetric.trim().isNotEmpty && + patternMetric.trim() != patternSummary?.trim()) + InfoRow(title: 'Pattern detail', value: patternMetric), if (drop.skin.collection != null && drop.skin.collection!.isNotEmpty) InfoRow(title: 'Collection', value: drop.skin.collection!), diff --git a/lib/presentation/widgets/skin_grid_tile.dart b/lib/presentation/widgets/skin_grid_tile.dart index 146de344..4a22bbba 100644 --- a/lib/presentation/widgets/skin_grid_tile.dart +++ b/lib/presentation/widgets/skin_grid_tile.dart @@ -7,12 +7,16 @@ class SkinGridTile extends StatelessWidget { final SkinDto skin; final bool highlighted; final int crossAxisCount; + final String? secondaryTextOverride; + final String? detailTextOverride; const SkinGridTile({ super.key, required this.skin, this.highlighted = false, required this.crossAxisCount, + this.secondaryTextOverride, + this.detailTextOverride, }); @override @@ -68,7 +72,7 @@ class SkinGridTile extends StatelessWidget { ), const SizedBox(height: 4), Text( - SkinUiHelper.secondaryText(skin), + secondaryTextOverride ?? SkinUiHelper.secondaryText(skin), textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, @@ -77,6 +81,19 @@ class SkinGridTile extends StatelessWidget { fontSize: compact ? 10 : 12, ), ), + if ((detailTextOverride ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + detailTextOverride!, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white54, + fontSize: 10, + ), + ), + ], const SizedBox(height: 4), Text( SkinUiHelper.rarityLabel(skin), diff --git a/lib/presentation/widgets/terminal_offer_card.dart b/lib/presentation/widgets/terminal_offer_card.dart index 22424aa3..d24ea109 100644 --- a/lib/presentation/widgets/terminal_offer_card.dart +++ b/lib/presentation/widgets/terminal_offer_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../domain/terminal_offer.dart'; +import '../../domain/skin_pattern_helper.dart'; import '../helpers/skin_ui_helper.dart'; import 'hold_to_confirm_button.dart'; import 'info_row.dart'; @@ -22,6 +23,16 @@ class TerminalOfferCard extends StatelessWidget { @override Widget build(BuildContext context) { final rarityColor = SkinUiHelper.rarityColor(offer.skin); + final patternSummary = SkinPatternHelper.describePattern( + skin: offer.skin, + patternSeed: offer.patternSeed, + ); + final patternMetric = SkinPatternHelper.describePatternMetric( + skin: offer.skin, + patternSeed: offer.patternSeed, + ); + final patternFamily = SkinPatternHelper.patternFamilyLabel(offer.skin); + final phaseText = (offer.skin.phase ?? '').trim(); return Card( margin: const EdgeInsets.all(12), @@ -78,6 +89,22 @@ class TerminalOfferCard extends StatelessWidget { value: offer.skinFloat?.toStringAsFixed(6) ?? '-', ), InfoRow(title: 'Exterior', value: offer.exterior ?? '-'), + if (phaseText.isNotEmpty) InfoRow(title: 'Phase', value: phaseText), + if (offer.patternSeed != null) + InfoRow( + title: 'Pattern seed', + value: offer.patternSeed.toString(), + ), + if (patternFamily != null) + InfoRow(title: 'Pattern family', value: patternFamily), + if (patternSummary != null && + patternSummary.trim().isNotEmpty && + patternSummary.trim() != phaseText) + InfoRow(title: 'Pattern', value: patternSummary), + if (patternMetric != null && + patternMetric.trim().isNotEmpty && + patternMetric.trim() != patternSummary?.trim()) + InfoRow(title: 'Pattern detail', value: patternMetric), if (offer.skin.collection != null && offer.skin.collection!.isNotEmpty) InfoRow(title: 'Collection', value: offer.skin.collection!), diff --git a/lib/presentation/widgets/tradeup_chance_card.dart b/lib/presentation/widgets/tradeup_chance_card.dart index 85e52293..30649d16 100644 --- a/lib/presentation/widgets/tradeup_chance_card.dart +++ b/lib/presentation/widgets/tradeup_chance_card.dart @@ -1,17 +1,34 @@ import 'package:flutter/material.dart'; +import '../../domain/skin_pattern_helper.dart'; import '../../domain/tradeup_service.dart'; import '../helpers/skin_ui_helper.dart'; class TradeUpChanceCard extends StatelessWidget { final TradeUpChance chance; + final double? probabilityOverride; + final String? detailOverride; - const TradeUpChanceCard({super.key, required this.chance}); + const TradeUpChanceCard({ + super.key, + required this.chance, + this.probabilityOverride, + this.detailOverride, + }); @override Widget build(BuildContext context) { final skin = chance.skin; final color = SkinUiHelper.rarityColor(skin); + final patternSummary = SkinPatternHelper.describePattern( + skin: skin, + patternSeed: null, + ); + final displayedProbability = probabilityOverride ?? chance.probability; + final displayedDetail = detailOverride; + final displayedSecondary = (displayedDetail ?? '').trim().isNotEmpty + ? skin.name + : SkinUiHelper.secondaryText(skin); return Card( margin: EdgeInsets.zero, @@ -46,7 +63,7 @@ class TradeUpChanceCard extends StatelessWidget { ), const SizedBox(height: 3), Text( - skin.name, + displayedSecondary, maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, @@ -54,7 +71,7 @@ class TradeUpChanceCard extends StatelessWidget { ), const SizedBox(height: 4), Text( - '${(chance.probability * 100).toStringAsFixed(2)}%', + '${(displayedProbability * 100).toStringAsFixed(2)}%', style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, @@ -69,6 +86,30 @@ class TradeUpChanceCard extends StatelessWidget { textAlign: TextAlign.center, style: const TextStyle(fontSize: 9, color: Colors.white70), ), + if ((displayedDetail ?? '').trim().isNotEmpty) + Text( + displayedDetail!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 9, color: Colors.white70), + ) + else if ((skin.phase ?? '').trim().isNotEmpty) + Text( + skin.phase!.trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 9, color: Colors.white70), + ) + else if (patternSummary != null) + Text( + patternSummary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 9, color: Colors.white70), + ), Text( 'FV ${chance.floatValue.toStringAsFixed(5)}', style: const TextStyle(fontSize: 9, color: Colors.white54), diff --git a/lib/presentation/widgets/tradeup_skin_tile.dart b/lib/presentation/widgets/tradeup_skin_tile.dart index 71e73255..360a335a 100644 --- a/lib/presentation/widgets/tradeup_skin_tile.dart +++ b/lib/presentation/widgets/tradeup_skin_tile.dart @@ -61,7 +61,7 @@ class TradeUpSkinTile extends StatelessWidget { ), const SizedBox(height: 3), Text( - skin.name, + SkinUiHelper.secondaryText(skin), maxLines: 2, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, diff --git a/lib/presentation/widgets/xray_reveal_card.dart b/lib/presentation/widgets/xray_reveal_card.dart index 5336f128..5cf25428 100644 --- a/lib/presentation/widgets/xray_reveal_card.dart +++ b/lib/presentation/widgets/xray_reveal_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../domain/dropped_skin.dart'; +import '../../domain/skin_pattern_helper.dart'; import '../helpers/skin_ui_helper.dart'; import 'info_row.dart'; @@ -286,6 +287,16 @@ class _XrayRevealCardState extends State Widget build(BuildContext context) { final skin = widget.drop.skin; final rarityColor = SkinUiHelper.rarityColor(skin); + final patternSummary = SkinPatternHelper.describePattern( + skin: skin, + patternSeed: widget.drop.patternSeed, + ); + final patternMetric = SkinPatternHelper.describePatternMetric( + skin: skin, + patternSeed: widget.drop.patternSeed, + ); + final patternFamily = SkinPatternHelper.patternFamilyLabel(skin); + final phaseText = (skin.phase ?? '').trim(); const xrayGlow = Color(0xFF78FFF0); const xrayPanel = Color(0xFF0B1A21); @@ -354,6 +365,29 @@ class _XrayRevealCardState extends State title: 'Exterior', value: widget.drop.exterior ?? '-', ), + if (phaseText.isNotEmpty) + InfoRow(title: 'Phase', value: phaseText), + if (widget.drop.patternSeed != null) + InfoRow( + title: 'Pattern seed', + value: widget.drop.patternSeed.toString(), + ), + if (patternFamily != null) + InfoRow( + title: 'Pattern family', + value: patternFamily, + ), + if (patternSummary != null && + patternSummary.trim().isNotEmpty && + patternSummary.trim() != phaseText) + InfoRow(title: 'Pattern', value: patternSummary), + if (patternMetric != null && + patternMetric.trim().isNotEmpty && + patternMetric.trim() != patternSummary?.trim()) + InfoRow( + title: 'Pattern detail', + value: patternMetric, + ), InfoRow( title: 'StatTrak', value: widget.drop.isStatTrak ? 'Yes' : 'No', diff --git a/pubspec.yaml b/pubspec.yaml index 256d806a..7e9ae7dd 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.11.0 +version: 0.12.0 environment: sdk: ^3.11.3