mirror of
https://github.com/sebastiandine/Card-Collection-Manager-3.git
synced 2026-08-29 01:08:49 +00:00
start of set completion tracking
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@ The `ccm` executable — composition root only. The single place where concrete
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
|
||||
1. **Composition root is the only place** that names concrete adapters: `StdFileSystem`, `CprHttpClient`, `JsonCollectionRepository<MagicCard>`, `JsonCollectionRepository<PokemonCard>`, `JsonCollectionRepository<YuGiOhCard>`, `JsonCollectionRepository<DigiBattle99Card>`, `JsonSetRepository`, `DigiBattle99SetCatalogService`, `LocalImageStore`, `LocalPreviewByteCache`, `MagicGameModule`, `PokemonGameModule`, `JapanesePokemonGameModule` (Asia sets/preview backend for unified Pokemon), `YuGiOhGameModule`, `DigiBattle99GameModule`, `MagicGameView`, `PokemonGameView`, `YuGiOhGameView`, `DigiBattle99GameView`, etc. If a concrete adapter type appears anywhere else in the codebase, move the wiring here.
|
||||
2. **Member declaration order in `CcmApp` matters** — destruction is reverse, so a member that depends on another (e.g. `magicCollSvc_` depends on `magicRepo_` and `imgStore_`; `previewSvc_` depends on `http_` and is consumed by `ctx_`; `magicView_` depends on the typed `magicCollSvc_` and the shared services) must be declared **after** its deps. Do not reorder casually.
|
||||
3. **Use `std::unique_ptr` for everything owned** by `CcmApp`. The `AppContext` then holds plain references into those owned objects, plus a vector of `IGameView*` raw pointers (the `unique_ptr<>`s for the views are the actual owners; the vector just describes the active set).
|
||||
4. **Game-to-directory mapping** lives in `dirNameForGame(Game)` (anonymous namespace). When adding a new game, extend this function — it is wired into all three repositories (`JsonCollectionRepository`, `JsonSetRepository`, `LocalImageStore`). Pokemon West (`Game::Pokemon`) and Asia (`Game::JapanesePokemon`) both map to `"pokemon"`; `JsonSetRepository` stores their set caches as `sets-west.json` / `sets-asia.json` in that directory (other games keep `sets.json`).
|
||||
|
||||
+5
-1
@@ -21,6 +21,7 @@
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
#include "ccm/services/CollectionService.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
|
||||
#include "ccm/services/ImageService.hpp"
|
||||
#include "ccm/services/SetService.hpp"
|
||||
#include "ccm/ui/AppContext.hpp"
|
||||
@@ -110,6 +111,8 @@ public:
|
||||
std::make_unique<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>>(
|
||||
*fs_, *config_, &dirNameForGame);
|
||||
setRepo_ = std::make_unique<ccm::JsonSetRepository>(*fs_, *config_, &dirNameForGame);
|
||||
digiBattle99CatalogStore_ =
|
||||
std::make_unique<ccm::DigiBattle99SetCatalogService>(*fs_, *config_, &dirNameForGame);
|
||||
imgStore_ = std::make_unique<ccm::LocalImageStore>(*fs_, *config_, &dirNameForGame);
|
||||
|
||||
imgSvc_ = std::make_unique<ccm::ImageService>(*imgStore_);
|
||||
@@ -163,7 +166,7 @@ public:
|
||||
*config_, *ygoCollSvc_, *setSvc_, *imgSvc_, *previewSvc_, *ygoMod_);
|
||||
digiBattle99View_ = std::make_unique<ccm::ui::DigiBattle99GameView>(
|
||||
*config_, *digiBattle99CollSvc_, *setSvc_, *imgSvc_, *previewSvc_,
|
||||
*digiBattle99Mod_);
|
||||
*digiBattle99Mod_, *digiBattle99CatalogStore_);
|
||||
|
||||
ctx_ = std::make_unique<ccm::ui::AppContext>(ccm::ui::AppContext{
|
||||
*config_,
|
||||
@@ -203,6 +206,7 @@ private:
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::YuGiOhCard>> ygoRepo_;
|
||||
std::unique_ptr<ccm::JsonCollectionRepository<ccm::DigiBattle99Card>> digiBattle99Repo_;
|
||||
std::unique_ptr<ccm::JsonSetRepository> setRepo_;
|
||||
std::unique_ptr<ccm::DigiBattle99SetCatalogService> digiBattle99CatalogStore_;
|
||||
std::unique_ptr<ccm::LocalImageStore> imgStore_;
|
||||
std::unique_ptr<ccm::ImageService> imgSvc_;
|
||||
std::unique_ptr<ccm::CollectionService<ccm::MagicCard>> magicCollSvc_;
|
||||
|
||||
+3
-3
@@ -4,11 +4,11 @@
|
||||
|
||||
## Layer pointers
|
||||
|
||||
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `DigiBattle99Card`, `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
|
||||
- `include/ccm/domain/` — POD value types: `Enums` (includes `PokemonRegion`), `Set`, `MagicCard`, `PokemonCard` (unified West/Asia via `region`), `YuGiOhCard`, `DigiBattle99Card`, `DigiBattle99SetCatalog` (Digi-Battle pack checklists for set completion), `JapanesePokemonCard` (legacy type retained for tests/serde; app collection uses `PokemonCard`), `Configuration`. Each has `to_json` / `from_json` defined in the matching `src/domain/*.cpp`.
|
||||
- `include/ccm/ports/` — interfaces (`IHttpClient`, `IFileSystem`, `ICollectionRepository<T>`, `ISetRepository`, `IImageStore`, `ICardPreviewSource`, `IPreviewByteCache`). All seams the services depend on. Add new ports here when adding new external concerns.
|
||||
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list). They depend only on ports.
|
||||
- `include/ccm/infra/` — concrete adapters: `CprHttpClient`, `StdFileSystem`, `JsonCollectionRepository<T>` (header-only template), `JsonSetRepository`, `LocalImageStore`, `LocalPreviewByteCache`.
|
||||
- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect.
|
||||
- `include/ccm/services/` — high-level operations: `ConfigService`, `CollectionService<TCard>` (header-only template), `SetService`, `ImageService`, `CardPreviewService`, `CardSorter` (free functions; per-column sort comparators that mirror established table sorting behavior — UI-agnostic so they can be unit-tested directly), `CardFilter` (free functions; case-insensitive substring row matcher restricted to each game's `tableFields` valueKey list), `DigiBattle99SetCompletion` (pure Digi-Battle set-completion / checklist helpers), `DigiBattle99SetCatalogService` (`digibattle99/set-catalog.json`). They depend only on ports / domain.
|
||||
- `include/ccm/games/` — `IGameModule` + per-game modules. `IGameModule` consolidates the per-game seams: every module owns an `ISetSource` (required) and may own an `ICardPreviewSource` (optional, default `nullptr`). `magic/`, `pokemon/`, `yugioh/`, `digibattle99/`, and `pokemonjp/` are the reference implementations — all five expose a fully working set source + card preview source. `DigiBattle99SetSource` also exposes `parseCatalog` / `fetchAllWithCatalog` for the set-completion checklist. `pokemonjp/` is the **Asia region backend** for the unified Pokemon UI (set cache at `pokemon/sets-asia.json`, same data dir as West; TCGdex JA previews); it is registered for sets/previews but is not a separate Game menu entry. Japanese Pokémon also loads an optional EN name catalog (`JapanesePokemonEnCatalog`) for display/auto-detect.
|
||||
- `include/ccm/util/` — `Result.hpp` (the sum type), `FsNames.hpp` (filename munging ported from `util/fs.rs`), `YuGiOhPrintingSlot.hpp` / `YuGiOhSetLookup.hpp` (Yu-Gi-Oh! print-slot helpers and cached-set **set code** lookup for the edit dialog; both header-only, unit-tested).
|
||||
- `src/` mirrors `include/ccm/` for non-template implementations.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ add_library(ccm_core STATIC
|
||||
src/domain/PokemonCard.cpp
|
||||
src/domain/YuGiOhCard.cpp
|
||||
src/domain/DigiBattle99Card.cpp
|
||||
src/domain/DigiBattle99SetCatalog.cpp
|
||||
src/domain/JapanesePokemonCard.cpp
|
||||
src/domain/Configuration.cpp
|
||||
|
||||
@@ -17,6 +18,8 @@ add_library(ccm_core STATIC
|
||||
src/services/CardPreviewService.cpp
|
||||
src/services/CardSorter.cpp
|
||||
src/services/CardFilter.cpp
|
||||
src/services/DigiBattle99SetCompletion.cpp
|
||||
src/services/DigiBattle99SetCatalogService.cpp
|
||||
|
||||
src/infra/CprHttpClient.cpp
|
||||
src/infra/StdFileSystem.cpp
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetCatalog: offline pack → card checklist for Digi-Battle set
|
||||
// completion. Filled from digimoncard.io bulk search.php (same payload as the
|
||||
// set list) and persisted at `<dataStorage>/digibattle99/set-catalog.json`.
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99CatalogCard {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
|
||||
friend bool operator==(const DigiBattle99CatalogCard&,
|
||||
const DigiBattle99CatalogCard&) = default;
|
||||
};
|
||||
|
||||
struct DigiBattle99SetCatalogPack {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::vector<DigiBattle99CatalogCard> cards;
|
||||
|
||||
friend bool operator==(const DigiBattle99SetCatalogPack&,
|
||||
const DigiBattle99SetCatalogPack&) = default;
|
||||
};
|
||||
|
||||
struct DigiBattle99SetCatalog {
|
||||
std::vector<DigiBattle99SetCatalogPack> packs;
|
||||
|
||||
[[nodiscard]] const DigiBattle99SetCatalogPack* findPack(
|
||||
std::string_view setId) const;
|
||||
|
||||
[[nodiscard]] bool empty() const noexcept { return packs.empty(); }
|
||||
|
||||
friend bool operator==(const DigiBattle99SetCatalog&,
|
||||
const DigiBattle99SetCatalog&) = default;
|
||||
};
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c);
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p);
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c);
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -3,12 +3,15 @@
|
||||
// DigiBattle99SetSource: ISetSource for Digimon Digi-Battle (1999 English).
|
||||
// digimoncard.io has no dedicated sets endpoint; we derive unique pack names
|
||||
// from a bulk search.php call scoped to series=Digimon Digi-Battle Card Game.
|
||||
// The same payload also builds the set-completion catalog (parseCatalog).
|
||||
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/games/IGameModule.hpp"
|
||||
#include "ccm/ports/IHttpClient.hpp"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
@@ -20,12 +23,21 @@ public:
|
||||
|
||||
static constexpr const char* kSeries = "Digimon Digi-Battle Card Game";
|
||||
|
||||
struct FetchWithCatalog {
|
||||
std::vector<Set> sets;
|
||||
DigiBattle99SetCatalog catalog;
|
||||
};
|
||||
|
||||
explicit DigiBattle99SetSource(IHttpClient& http);
|
||||
|
||||
Result<std::vector<Set>> fetchAll() override;
|
||||
|
||||
// Pure parser exposed for unit testing without a network round-trip.
|
||||
// One HTTP round-trip producing both the set list and the pack catalog.
|
||||
Result<FetchWithCatalog> fetchAllWithCatalog();
|
||||
|
||||
// Pure parsers exposed for unit testing without a network round-trip.
|
||||
static Result<std::vector<Set>> parseResponse(const std::string& body);
|
||||
static Result<DigiBattle99SetCatalog> parseCatalog(const std::string& body);
|
||||
|
||||
// Stable Set.id from a pack display name (ASCII lower, non-alnum -> '-').
|
||||
static std::string slugifyPackName(std::string_view packName);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetCatalogService: load/save digibattle99/set-catalog.json under
|
||||
// the configured dataStorage path.
|
||||
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/ports/IFileSystem.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/util/Result.hpp"
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
class DigiBattle99SetCatalogService {
|
||||
public:
|
||||
using DirNameFn = std::function<std::string(Game)>;
|
||||
|
||||
DigiBattle99SetCatalogService(IFileSystem& fs, ConfigService& config, DirNameFn dirName);
|
||||
|
||||
Result<DigiBattle99SetCatalog> load() const;
|
||||
Result<void> save(const DigiBattle99SetCatalog& catalog);
|
||||
|
||||
[[nodiscard]] bool exists() const;
|
||||
|
||||
private:
|
||||
IFileSystem& fs_;
|
||||
ConfigService& config_;
|
||||
DirNameFn dirName_;
|
||||
|
||||
[[nodiscard]] std::filesystem::path catalogPath() const;
|
||||
};
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
// Pure helpers: Digi-Battle set-completion progress and per-set checklists.
|
||||
// Ownership counts only when collection card.set.id matches the pack and the
|
||||
// normalized setNo appears in that pack's catalog. Duplicates / amount do not
|
||||
// inflate the numerator.
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
struct DigiBattle99SetCompletionProgress {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::size_t ownedUnique{0};
|
||||
std::size_t total{0};
|
||||
|
||||
[[nodiscard]] int percent() const noexcept {
|
||||
if (total == 0) return 0;
|
||||
return static_cast<int>((ownedUnique * 100) / total);
|
||||
}
|
||||
};
|
||||
|
||||
struct DigiBattle99ChecklistEntry {
|
||||
std::string setNo;
|
||||
std::string name;
|
||||
bool owned{false};
|
||||
};
|
||||
|
||||
// Packs where the collection owns ≥1 card with matching set.id, ordered by
|
||||
// setName. Packs absent from the catalog are skipped.
|
||||
[[nodiscard]] std::vector<DigiBattle99SetCompletionProgress>
|
||||
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog);
|
||||
|
||||
// Full catalog checklist for one pack; owned flags from the collection.
|
||||
[[nodiscard]] std::vector<DigiBattle99ChecklistEntry>
|
||||
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::string_view setId);
|
||||
|
||||
} // namespace ccm
|
||||
@@ -27,6 +27,10 @@ public:
|
||||
// repository, and return the new list.
|
||||
Result<std::vector<Set>> updateSets(Game game);
|
||||
|
||||
// Persist an already-fetched set list (no HTTP). Used when a game-specific
|
||||
// Update Sets path fetches sets + side payloads in one round-trip.
|
||||
Result<void> saveSets(Game game, const std::vector<Set>& sets);
|
||||
|
||||
// Cached read; returns an error if no local data exists yet.
|
||||
Result<std::vector<Set>> getSets(Game game);
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
|
||||
namespace ccm {
|
||||
|
||||
const DigiBattle99SetCatalogPack* DigiBattle99SetCatalog::findPack(
|
||||
std::string_view setId) const {
|
||||
for (const auto& pack : packs) {
|
||||
if (pack.setId == setId) return &pack;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99CatalogCard& c) {
|
||||
j = nlohmann::json{{"setNo", c.setNo}, {"name", c.name}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99CatalogCard& c) {
|
||||
j.at("setNo").get_to(c.setNo);
|
||||
j.at("name").get_to(c.name);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalogPack& p) {
|
||||
j = nlohmann::json{{"id", p.setId}, {"name", p.setName}, {"cards", p.cards}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalogPack& p) {
|
||||
j.at("id").get_to(p.setId);
|
||||
j.at("name").get_to(p.setName);
|
||||
j.at("cards").get_to(p.cards);
|
||||
}
|
||||
|
||||
void to_json(nlohmann::json& j, const DigiBattle99SetCatalog& c) {
|
||||
j = nlohmann::json{{"packs", c.packs}};
|
||||
}
|
||||
|
||||
void from_json(const nlohmann::json& j, DigiBattle99SetCatalog& c) {
|
||||
j.at("packs").get_to(c.packs);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -40,6 +42,24 @@ std::string releaseDateForPack(const std::string& packName) {
|
||||
return {};
|
||||
}
|
||||
|
||||
Result<nlohmann::json> parseSearchArray(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return Result<nlohmann::json>::err(
|
||||
j.value("error", std::string{"digimoncard.io set search error"}));
|
||||
}
|
||||
if (!j.is_array()) {
|
||||
return Result<nlohmann::json>::err(
|
||||
"digimoncard.io Digi-Battle response is not a JSON array.");
|
||||
}
|
||||
return Result<nlohmann::json>::ok(j);
|
||||
} catch (const std::exception& e) {
|
||||
return Result<nlohmann::json>::err(
|
||||
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DigiBattle99SetSource::DigiBattle99SetSource(IHttpClient& http) : http_(http) {}
|
||||
@@ -61,59 +81,126 @@ std::string DigiBattle99SetSource::slugifyPackName(std::string_view packName) {
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> DigiBattle99SetSource::parseResponse(const std::string& body) {
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(body);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
j.value("error", std::string{"digimoncard.io set search error"}));
|
||||
}
|
||||
if (!j.is_array()) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
"digimoncard.io Digi-Battle response is not a JSON array.");
|
||||
}
|
||||
auto arr = parseSearchArray(body);
|
||||
if (!arr) return Result<std::vector<Set>>::err(arr.error());
|
||||
|
||||
// Preserve first-seen order of pack names, then sort by release date.
|
||||
std::unordered_set<std::string> seen;
|
||||
std::vector<std::string> packNames;
|
||||
packNames.reserve(16);
|
||||
for (const auto& entry : j) {
|
||||
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
|
||||
for (const auto& pack : entry.at("set_name")) {
|
||||
if (!pack.is_string()) continue;
|
||||
const std::string name = pack.get<std::string>();
|
||||
if (name.empty()) continue;
|
||||
if (seen.insert(name).second) packNames.push_back(name);
|
||||
}
|
||||
// Preserve first-seen order of pack names, then sort by release date.
|
||||
std::unordered_set<std::string> seen;
|
||||
std::vector<std::string> packNames;
|
||||
packNames.reserve(16);
|
||||
for (const auto& entry : arr.value()) {
|
||||
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
|
||||
for (const auto& pack : entry.at("set_name")) {
|
||||
if (!pack.is_string()) continue;
|
||||
const std::string name = pack.get<std::string>();
|
||||
if (name.empty()) continue;
|
||||
if (seen.insert(name).second) packNames.push_back(name);
|
||||
}
|
||||
|
||||
std::vector<Set> out;
|
||||
out.reserve(packNames.size());
|
||||
for (const auto& name : packNames) {
|
||||
Set s;
|
||||
s.id = slugifyPackName(name);
|
||||
s.name = name;
|
||||
s.releaseDate = releaseDateForPack(name);
|
||||
if (s.id.empty()) continue;
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
|
||||
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
|
||||
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
|
||||
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
} catch (const std::exception& e) {
|
||||
return Result<std::vector<Set>>::err(
|
||||
std::string("digimoncard.io Digi-Battle JSON parse error: ") + e.what());
|
||||
}
|
||||
|
||||
std::vector<Set> out;
|
||||
out.reserve(packNames.size());
|
||||
for (const auto& name : packNames) {
|
||||
Set s;
|
||||
s.id = slugifyPackName(name);
|
||||
s.name = name;
|
||||
s.releaseDate = releaseDateForPack(name);
|
||||
if (s.id.empty()) continue;
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(), [](const Set& a, const Set& b) {
|
||||
if (a.releaseDate.empty() && !b.releaseDate.empty()) return false;
|
||||
if (!a.releaseDate.empty() && b.releaseDate.empty()) return true;
|
||||
if (a.releaseDate != b.releaseDate) return a.releaseDate < b.releaseDate;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return Result<std::vector<Set>>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<DigiBattle99SetCatalog> DigiBattle99SetSource::parseCatalog(const std::string& body) {
|
||||
auto arr = parseSearchArray(body);
|
||||
if (!arr) return Result<DigiBattle99SetCatalog>::err(arr.error());
|
||||
|
||||
// pack display name -> (setId, ordered unique cards by first-seen setNo)
|
||||
struct PackBuild {
|
||||
std::string setId;
|
||||
std::string setName;
|
||||
std::unordered_set<std::string> seenNos;
|
||||
std::vector<DigiBattle99CatalogCard> cards;
|
||||
};
|
||||
std::unordered_map<std::string, PackBuild> byName;
|
||||
|
||||
for (const auto& entry : arr.value()) {
|
||||
if (!entry.contains("name") || !entry.at("name").is_string()) continue;
|
||||
if (!entry.contains("id") || !entry.at("id").is_string()) continue;
|
||||
if (!entry.contains("set_name") || !entry.at("set_name").is_array()) continue;
|
||||
|
||||
DigiBattle99CatalogCard card;
|
||||
card.name = entry.at("name").get<std::string>();
|
||||
card.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(
|
||||
entry.at("id").get<std::string>());
|
||||
if (card.setNo.empty()) continue;
|
||||
|
||||
for (const auto& pack : entry.at("set_name")) {
|
||||
if (!pack.is_string()) continue;
|
||||
const std::string packName = pack.get<std::string>();
|
||||
if (packName.empty()) continue;
|
||||
|
||||
auto& build = byName[packName];
|
||||
if (build.setName.empty()) {
|
||||
build.setName = packName;
|
||||
build.setId = slugifyPackName(packName);
|
||||
}
|
||||
if (build.setId.empty()) continue;
|
||||
if (!build.seenNos.insert(card.setNo).second) continue;
|
||||
build.cards.push_back(card);
|
||||
}
|
||||
}
|
||||
|
||||
DigiBattle99SetCatalog catalog;
|
||||
catalog.packs.reserve(byName.size());
|
||||
for (auto& [_, build] : byName) {
|
||||
if (build.setId.empty()) continue;
|
||||
std::sort(build.cards.begin(), build.cards.end(),
|
||||
[](const DigiBattle99CatalogCard& a, const DigiBattle99CatalogCard& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
DigiBattle99SetCatalogPack pack;
|
||||
pack.setId = std::move(build.setId);
|
||||
pack.setName = std::move(build.setName);
|
||||
pack.cards = std::move(build.cards);
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
}
|
||||
|
||||
std::sort(catalog.packs.begin(), catalog.packs.end(),
|
||||
[](const DigiBattle99SetCatalogPack& a, const DigiBattle99SetCatalogPack& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
return Result<DigiBattle99SetCatalog>::ok(std::move(catalog));
|
||||
}
|
||||
|
||||
Result<DigiBattle99SetSource::FetchWithCatalog>
|
||||
DigiBattle99SetSource::fetchAllWithCatalog() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<FetchWithCatalog>::err(resp.error());
|
||||
|
||||
auto sets = parseResponse(resp.value());
|
||||
if (!sets) return Result<FetchWithCatalog>::err(sets.error());
|
||||
auto catalog = parseCatalog(resp.value());
|
||||
if (!catalog) return Result<FetchWithCatalog>::err(catalog.error());
|
||||
|
||||
FetchWithCatalog out;
|
||||
out.sets = std::move(sets).value();
|
||||
out.catalog = std::move(catalog).value();
|
||||
return Result<FetchWithCatalog>::ok(std::move(out));
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> DigiBattle99SetSource::fetchAll() {
|
||||
auto resp = http_.get(kEndpoint);
|
||||
if (!resp) return Result<std::vector<Set>>::err(resp.error());
|
||||
return parseResponse(resp.value());
|
||||
auto both = fetchAllWithCatalog();
|
||||
if (!both) return Result<std::vector<Set>>::err(both.error());
|
||||
return Result<std::vector<Set>>::ok(std::move(both).value().sets);
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
DigiBattle99SetCatalogService::DigiBattle99SetCatalogService(IFileSystem& fs,
|
||||
ConfigService& config,
|
||||
DirNameFn dirName)
|
||||
: fs_(fs), config_(config), dirName_(std::move(dirName)) {}
|
||||
|
||||
fs::path DigiBattle99SetCatalogService::catalogPath() const {
|
||||
return fs::path(config_.current().dataStorage) / dirName_(Game::DigiBattle99) /
|
||||
"set-catalog.json";
|
||||
}
|
||||
|
||||
bool DigiBattle99SetCatalogService::exists() const {
|
||||
return fs_.exists(catalogPath());
|
||||
}
|
||||
|
||||
Result<DigiBattle99SetCatalog> DigiBattle99SetCatalogService::load() const {
|
||||
const auto p = catalogPath();
|
||||
if (!fs_.exists(p)) {
|
||||
return Result<DigiBattle99SetCatalog>::err(
|
||||
"Digimon Digi-Battle set catalog not yet downloaded.");
|
||||
}
|
||||
auto text = fs_.readText(p);
|
||||
if (!text) return Result<DigiBattle99SetCatalog>::err(text.error());
|
||||
try {
|
||||
const auto j = nlohmann::json::parse(text.value());
|
||||
return Result<DigiBattle99SetCatalog>::ok(j.get<DigiBattle99SetCatalog>());
|
||||
} catch (const std::exception& e) {
|
||||
return Result<DigiBattle99SetCatalog>::err(
|
||||
std::string("set-catalog.json parse error: ") + e.what());
|
||||
}
|
||||
}
|
||||
|
||||
Result<void> DigiBattle99SetCatalogService::save(const DigiBattle99SetCatalog& catalog) {
|
||||
const auto p = catalogPath();
|
||||
auto dir = fs_.ensureDirectory(p.parent_path());
|
||||
if (!dir) return dir;
|
||||
const nlohmann::json j = catalog;
|
||||
return fs_.writeText(p, j.dump(2));
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -0,0 +1,98 @@
|
||||
#include "ccm/services/DigiBattle99SetCompletion.hpp"
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99CardPreviewSource.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace ccm {
|
||||
|
||||
namespace {
|
||||
|
||||
using OwnedBySet = std::unordered_map<std::string, std::unordered_set<std::string>>;
|
||||
|
||||
OwnedBySet ownedSetNosBySetId(const std::vector<DigiBattle99Card>& collection) {
|
||||
OwnedBySet out;
|
||||
for (const auto& card : collection) {
|
||||
if (card.set.id.empty()) continue;
|
||||
const std::string setNo =
|
||||
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
if (setNo.empty()) continue;
|
||||
out[card.set.id].insert(setNo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<DigiBattle99SetCompletionProgress>
|
||||
computeDigiBattle99SetCompletion(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog) {
|
||||
const OwnedBySet owned = ownedSetNosBySetId(collection);
|
||||
|
||||
std::vector<DigiBattle99SetCompletionProgress> out;
|
||||
out.reserve(owned.size());
|
||||
|
||||
for (const auto& [setId, ownedNos] : owned) {
|
||||
const auto* pack = catalog.findPack(setId);
|
||||
if (pack == nullptr || pack->cards.empty()) continue;
|
||||
|
||||
std::size_t matched = 0;
|
||||
for (const auto& card : pack->cards) {
|
||||
const std::string catalogNo =
|
||||
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
if (!catalogNo.empty() && ownedNos.count(catalogNo) != 0) ++matched;
|
||||
}
|
||||
|
||||
DigiBattle99SetCompletionProgress row;
|
||||
row.setId = pack->setId;
|
||||
row.setName = pack->setName;
|
||||
row.ownedUnique = matched;
|
||||
row.total = pack->cards.size();
|
||||
out.push_back(std::move(row));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const DigiBattle99SetCompletionProgress& a,
|
||||
const DigiBattle99SetCompletionProgress& b) {
|
||||
return a.setName < b.setName;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<DigiBattle99ChecklistEntry>
|
||||
digiBattle99ChecklistForSet(const std::vector<DigiBattle99Card>& collection,
|
||||
const DigiBattle99SetCatalog& catalog,
|
||||
std::string_view setId) {
|
||||
const auto* pack = catalog.findPack(setId);
|
||||
if (pack == nullptr) return {};
|
||||
|
||||
std::unordered_set<std::string> ownedNos;
|
||||
for (const auto& card : collection) {
|
||||
if (card.set.id != setId) continue;
|
||||
const std::string setNo =
|
||||
DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
if (!setNo.empty()) ownedNos.insert(setNo);
|
||||
}
|
||||
|
||||
std::vector<DigiBattle99ChecklistEntry> out;
|
||||
out.reserve(pack->cards.size());
|
||||
for (const auto& card : pack->cards) {
|
||||
DigiBattle99ChecklistEntry entry;
|
||||
entry.setNo = DigiBattle99CardPreviewSource::normalizeCardNumber(card.setNo);
|
||||
entry.name = card.name;
|
||||
entry.owned = !entry.setNo.empty() && ownedNos.count(entry.setNo) != 0;
|
||||
out.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const DigiBattle99ChecklistEntry& a,
|
||||
const DigiBattle99ChecklistEntry& b) {
|
||||
if (a.setNo != b.setNo) return a.setNo < b.setNo;
|
||||
return a.name < b.name;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace ccm
|
||||
@@ -20,6 +20,10 @@ Result<std::vector<Set>> SetService::updateSets(Game game) {
|
||||
return fetched;
|
||||
}
|
||||
|
||||
Result<void> SetService::saveSets(Game game, const std::vector<Set>& sets) {
|
||||
return repo_.save(game, sets);
|
||||
}
|
||||
|
||||
Result<std::vector<Set>> SetService::getSets(Game game) {
|
||||
auto loaded = repo_.load(game);
|
||||
if (!loaded) return loaded;
|
||||
|
||||
@@ -100,6 +100,19 @@ and collects unique `set_name[]` pack strings. Each pack becomes a `Set` with:
|
||||
|
||||
Unknown future packs get an empty release date and sort last.
|
||||
|
||||
Cached on disk as `<dataStorage>/digibattle99/sets.json` via `SetService` / `JsonSetRepository`.
|
||||
|
||||
### Set-completion catalog (same `search.php` payload)
|
||||
|
||||
**Sets → Update Digimon (Digi-Battle)** uses `DigiBattle99SetSource::fetchAllWithCatalog()` so one HTTP response writes both:
|
||||
|
||||
1. The set list (`sets.json`) as above
|
||||
2. A pack checklist at `<dataStorage>/digibattle99/set-catalog.json`
|
||||
|
||||
Each catalog pack stores `id` (slug), `name` (display), and `cards[]` of `{ setNo, name }` (API `id` normalized like preview — alphabetic prefix uppercased). A card listed in multiple `set_name[]` packs appears under **each** pack. The Digimon **Set Completion** tab reads this file offline (no live HTTP while browsing); ownership for a pack requires matching `card.set.id` plus normalized `setNo`.
|
||||
|
||||
If `set-catalog.json` is missing, the Set Completion tab prompts the user to run Update Digimon (Digi-Battle).
|
||||
|
||||
### Asset API: CDN images + `search.php` lookup
|
||||
|
||||
Card scans live at:
|
||||
|
||||
+2
-1
@@ -21,7 +21,8 @@
|
||||
- `std_file_system_tests.cpp` — `StdFileSystem` directly (`exists`, `isDirectory`, `ensureDirectory`, `readText`, `writeText`, `copyFile`, `remove`, `listDirectory`) under a unique `temp_directory_path()/ccm_std_fs_test_*` directory per case; scope matches the real-disk exception documented for preview-cache tests.
|
||||
- `pokemon_set_source_tests.cpp` — `PokemonSetSource::parseResponse` (api.pokemontcg.io/v2/sets shape — `data[].id`, `name`, `releaseDate` already in `YYYY/MM/DD`) + sort-by-release-date stability. Drives `fetchAll` via `FixedHttpClient` and asserts the public endpoint URL.
|
||||
- `pokemon_card_preview_source_tests.cpp` — `PokemonCardPreviewSource::buildSearchUrl` (percent-encoded `name:` / `set.id:` / `number:` triple, with collector-number `4/102` -> `4` normalization) + `parseResponse` (`data[0].images.large` with `images.small` fallback). Drives `fetchImageUrl` via `FixedHttpClient`.
|
||||
- `digibattle99_set_source_tests.cpp` — `DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. Drives `fetchAll` via `FixedHttpClient`.
|
||||
- `digibattle99_set_source_tests.cpp` — `DigiBattle99SetSource::parseResponse` derives unique packs from digimoncard.io search arrays, slugifies `Set.id`, applies curated release dates, and sorts chronologically. `parseCatalog` / `fetchAllWithCatalog` pin the set-completion checklist (multi-pack membership, setNo dedupe). Drives `fetchAll` via `FixedHttpClient`.
|
||||
- `digibattle99_set_completion_tests.cpp` — `computeDigiBattle99SetCompletion` / `digiBattle99ChecklistForSet` ownership rules + `DigiBattle99SetCatalogService` round-trip against `InMemoryFileSystem`.
|
||||
- `digibattle99_card_preview_source_tests.cpp` — CDN image URL from `setNo`, search URL encoding (`series`/`n`/`pack`/`card`), `parseImageUrlFromSearch` NotFound vs Transient, and auto-detect print variants. Drives `fetchImageUrl` / `detectPrintVariants` via `FixedHttpClient`.
|
||||
- `yugioh_set_source_tests.cpp` — `YuGiOhSetSource::parseResponse` for YGOPRODeck `cardsets.php` (`set_code`, `set_name`, `tcg_date`) including `YYYY-MM-DD` -> `YYYY/MM/DD` rewrite and chronological sort checks.
|
||||
- `yugioh_set_lookup_tests.cpp` — `lookupYuGiOhSetByShorthand` / helpers in `ccm/util/YuGiOhSetLookup.hpp` (trim, ASCII case-fold, exact `Set.id` match, not-found vs ambiguous).
|
||||
|
||||
@@ -23,6 +23,7 @@ add_executable(ccm_core_tests
|
||||
pokemon_card_preview_source_tests.cpp
|
||||
digibattle99_set_source_tests.cpp
|
||||
digibattle99_card_preview_source_tests.cpp
|
||||
digibattle99_set_completion_tests.cpp
|
||||
japanese_pokemon_en_catalog_tests.cpp
|
||||
japanese_pokemon_set_source_tests.cpp
|
||||
japanese_pokemon_card_preview_source_tests.cpp
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
#include <doctest/doctest.h>
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
|
||||
#include "ccm/services/DigiBattle99SetCompletion.hpp"
|
||||
#include "fakes/InMemoryFileSystem.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using namespace ccm;
|
||||
using ccm::testing::InMemoryFileSystem;
|
||||
|
||||
namespace {
|
||||
|
||||
ConfigService makeConfig(InMemoryFileSystem& fs, const std::string& dataDir) {
|
||||
Configuration c;
|
||||
c.dataStorage = dataDir;
|
||||
c.defaultGame = Game::Magic;
|
||||
fs.writeText("/app/config.json", nlohmann::json(c).dump());
|
||||
ConfigService cfg{fs, "/app/config.json", dataDir};
|
||||
cfg.initialize();
|
||||
return cfg;
|
||||
}
|
||||
|
||||
DigiBattle99Card makeOwned(std::string setId, std::string setName, std::string setNo) {
|
||||
DigiBattle99Card c;
|
||||
c.id = 1;
|
||||
c.name = "Owned";
|
||||
c.set.id = std::move(setId);
|
||||
c.set.name = std::move(setName);
|
||||
c.setNo = std::move(setNo);
|
||||
return c;
|
||||
}
|
||||
|
||||
DigiBattle99SetCatalog sampleCatalog() {
|
||||
DigiBattle99SetCatalog catalog;
|
||||
DigiBattle99SetCatalogPack starter;
|
||||
starter.setId = "series-1-starter-set";
|
||||
starter.setName = "Series 1 Starter Set";
|
||||
starter.cards = {
|
||||
{"ST-01", "Agumon"},
|
||||
{"ST-02", "Greymon"},
|
||||
{"ST-03", "Gabumon"},
|
||||
};
|
||||
DigiBattle99SetCatalogPack booster;
|
||||
booster.setId = "series-1-booster-pack";
|
||||
booster.setName = "Series 1 Booster Pack";
|
||||
booster.cards = {
|
||||
{"ST-01", "Agumon"},
|
||||
{"BO-01", "MetalGreymon"},
|
||||
};
|
||||
catalog.packs.push_back(std::move(booster));
|
||||
catalog.packs.push_back(std::move(starter));
|
||||
return catalog;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_SUITE("computeDigiBattle99SetCompletion") {
|
||||
TEST_CASE("only packs with owned cards appear") {
|
||||
const auto catalog = sampleCatalog();
|
||||
std::vector<DigiBattle99Card> collection{
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
|
||||
};
|
||||
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
|
||||
REQUIRE(rows.size() == 1);
|
||||
CHECK(rows[0].setId == "series-1-starter-set");
|
||||
CHECK(rows[0].ownedUnique == 1);
|
||||
CHECK(rows[0].total == 3);
|
||||
CHECK(rows[0].percent() == 33);
|
||||
}
|
||||
|
||||
TEST_CASE("unique setNo within a pack; amount does not inflate") {
|
||||
const auto catalog = sampleCatalog();
|
||||
DigiBattle99Card a = makeOwned("series-1-starter-set", "Series 1 Starter Set", "st-01");
|
||||
a.amount = 4;
|
||||
DigiBattle99Card b = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01");
|
||||
b.id = 2;
|
||||
DigiBattle99Card c = makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02");
|
||||
c.id = 3;
|
||||
const auto rows =
|
||||
computeDigiBattle99SetCompletion({a, b, c}, catalog);
|
||||
REQUIRE(rows.size() == 1);
|
||||
CHECK(rows[0].ownedUnique == 2);
|
||||
CHECK(rows[0].total == 3);
|
||||
CHECK(rows[0].percent() == 66);
|
||||
}
|
||||
|
||||
TEST_CASE("ownership on one pack does not complete another pack sharing setNo") {
|
||||
const auto catalog = sampleCatalog();
|
||||
std::vector<DigiBattle99Card> collection{
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
|
||||
};
|
||||
const auto rows = computeDigiBattle99SetCompletion(collection, catalog);
|
||||
REQUIRE(rows.size() == 1);
|
||||
CHECK(rows[0].setId == "series-1-starter-set");
|
||||
}
|
||||
|
||||
TEST_CASE("empty catalog yields no rows") {
|
||||
DigiBattle99SetCatalog empty;
|
||||
std::vector<DigiBattle99Card> collection{
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
|
||||
};
|
||||
CHECK(computeDigiBattle99SetCompletion(collection, empty).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("owned set missing from catalog is skipped") {
|
||||
DigiBattle99SetCatalog catalog;
|
||||
DigiBattle99SetCatalogPack onlyBooster;
|
||||
onlyBooster.setId = "series-1-booster-pack";
|
||||
onlyBooster.setName = "Series 1 Booster Pack";
|
||||
onlyBooster.cards = {{"BO-01", "MetalGreymon"}};
|
||||
catalog.packs.push_back(std::move(onlyBooster));
|
||||
|
||||
std::vector<DigiBattle99Card> collection{
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-01"),
|
||||
};
|
||||
CHECK(computeDigiBattle99SetCompletion(collection, catalog).empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("digiBattle99ChecklistForSet") {
|
||||
TEST_CASE("greys missing cards and marks owned ones") {
|
||||
const auto catalog = sampleCatalog();
|
||||
std::vector<DigiBattle99Card> collection{
|
||||
makeOwned("series-1-starter-set", "Series 1 Starter Set", "ST-02"),
|
||||
};
|
||||
const auto list =
|
||||
digiBattle99ChecklistForSet(collection, catalog, "series-1-starter-set");
|
||||
REQUIRE(list.size() == 3);
|
||||
CHECK(list[0].setNo == "ST-01");
|
||||
CHECK(list[0].owned == false);
|
||||
CHECK(list[1].setNo == "ST-02");
|
||||
CHECK(list[1].owned == true);
|
||||
CHECK(list[2].setNo == "ST-03");
|
||||
CHECK(list[2].owned == false);
|
||||
}
|
||||
|
||||
TEST_CASE("unknown set returns empty") {
|
||||
const auto catalog = sampleCatalog();
|
||||
CHECK(digiBattle99ChecklistForSet({}, catalog, "missing").empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99SetCatalogService") {
|
||||
TEST_CASE("save then load round-trips") {
|
||||
InMemoryFileSystem fs;
|
||||
auto config = makeConfig(fs, "/data");
|
||||
DigiBattle99SetCatalogService store{fs, config, [](Game) { return "digibattle99"; }};
|
||||
|
||||
CHECK_FALSE(store.exists());
|
||||
CHECK(store.load().isErr());
|
||||
|
||||
const auto catalog = sampleCatalog();
|
||||
REQUIRE(store.save(catalog).isOk());
|
||||
CHECK(store.exists());
|
||||
|
||||
const auto loaded = store.load();
|
||||
REQUIRE(loaded.isOk());
|
||||
CHECK(loaded.value() == catalog);
|
||||
}
|
||||
}
|
||||
@@ -109,4 +109,75 @@ TEST_SUITE("DigiBattle99SetSource::fetchAll") {
|
||||
CHECK(out.value().front().id == "series-1-starter-set");
|
||||
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
|
||||
}
|
||||
|
||||
TEST_CASE("fetchAllWithCatalog returns sets and pack cards in one GET") {
|
||||
FixedHttpClient http;
|
||||
http.ok = true;
|
||||
http.body = R"([
|
||||
{"name":"Agumon","id":"st-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
|
||||
{"name":"Greymon","id":"ST-02","set_name":["Series 1 Starter Set"]}
|
||||
])";
|
||||
DigiBattle99SetSource src{http};
|
||||
const auto out = src.fetchAllWithCatalog();
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().sets.size() == 2);
|
||||
const auto* starter = out.value().catalog.findPack("series-1-starter-set");
|
||||
REQUIRE(starter != nullptr);
|
||||
REQUIRE(starter->cards.size() == 2);
|
||||
CHECK(starter->cards[0].setNo == "ST-01");
|
||||
CHECK(starter->cards[0].name == "Agumon");
|
||||
const auto* booster = out.value().catalog.findPack("series-1-booster-pack");
|
||||
REQUIRE(booster != nullptr);
|
||||
REQUIRE(booster->cards.size() == 1);
|
||||
CHECK(booster->cards[0].setNo == "ST-01");
|
||||
CHECK(http.lastUrl == DigiBattle99SetSource::kEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99SetSource::parseCatalog") {
|
||||
TEST_CASE("lists a card under every pack in set_name") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set","Series 1 Booster Pack"]},
|
||||
{"name":"MetalGreymon","id":"BO-01","set_name":["Series 1 Booster Pack"]}
|
||||
])";
|
||||
const auto out = DigiBattle99SetSource::parseCatalog(json);
|
||||
REQUIRE(out.isOk());
|
||||
REQUIRE(out.value().packs.size() == 2);
|
||||
|
||||
const auto* booster = out.value().findPack("series-1-booster-pack");
|
||||
REQUIRE(booster != nullptr);
|
||||
REQUIRE(booster->cards.size() == 2);
|
||||
CHECK(booster->cards[0].setNo == "BO-01");
|
||||
CHECK(booster->cards[1].setNo == "ST-01");
|
||||
|
||||
const auto* starter = out.value().findPack("series-1-starter-set");
|
||||
REQUIRE(starter != nullptr);
|
||||
REQUIRE(starter->cards.size() == 1);
|
||||
CHECK(starter->cards[0].setNo == "ST-01");
|
||||
}
|
||||
|
||||
TEST_CASE("dedupes the same setNo within one pack") {
|
||||
const std::string json = R"([
|
||||
{"name":"Agumon","id":"ST-01","set_name":["Series 1 Starter Set"]},
|
||||
{"name":"Agumon Alt","id":"ST-01","set_name":["Series 1 Starter Set"]}
|
||||
])";
|
||||
const auto out = DigiBattle99SetSource::parseCatalog(json);
|
||||
REQUIRE(out.isOk());
|
||||
const auto* starter = out.value().findPack("series-1-starter-set");
|
||||
REQUIRE(starter != nullptr);
|
||||
REQUIRE(starter->cards.size() == 1);
|
||||
CHECK(starter->cards[0].name == "Agumon");
|
||||
}
|
||||
|
||||
TEST_CASE("empty array returns an empty catalog") {
|
||||
const auto out = DigiBattle99SetSource::parseCatalog("[]");
|
||||
REQUIRE(out.isOk());
|
||||
CHECK(out.value().empty());
|
||||
}
|
||||
|
||||
TEST_CASE("error object is an error") {
|
||||
const auto out = DigiBattle99SetSource::parseCatalog(
|
||||
R"({"error":"No cards found for this search."})");
|
||||
CHECK(out.isErr());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "ccm/domain/Configuration.hpp"
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/domain/Enums.hpp"
|
||||
#include "ccm/domain/JapanesePokemonCard.hpp"
|
||||
#include "ccm/domain/MagicCard.hpp"
|
||||
@@ -245,6 +246,28 @@ TEST_SUITE("DigiBattle99Card JSON") {
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("DigiBattle99SetCatalog JSON") {
|
||||
TEST_CASE("round-trips packs and setNo alias") {
|
||||
DigiBattle99SetCatalog catalog;
|
||||
DigiBattle99SetCatalogPack pack;
|
||||
pack.setId = "series-1-starter-set";
|
||||
pack.setName = "Series 1 Starter Set";
|
||||
pack.cards.push_back(DigiBattle99CatalogCard{"ST-01", "Agumon"});
|
||||
pack.cards.push_back(DigiBattle99CatalogCard{"ST-126", "Agumon"});
|
||||
catalog.packs.push_back(std::move(pack));
|
||||
|
||||
nlohmann::json j = catalog;
|
||||
CHECK(j.at("packs").is_array());
|
||||
CHECK(j.at("packs").at(0).at("id") == "series-1-starter-set");
|
||||
CHECK(j.at("packs").at(0).at("cards").at(0).at("setNo") == "ST-01");
|
||||
|
||||
const DigiBattle99SetCatalog back = j.get<DigiBattle99SetCatalog>();
|
||||
CHECK(back == catalog);
|
||||
CHECK(back.findPack("series-1-starter-set") != nullptr);
|
||||
CHECK(back.findPack("missing") == nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_SUITE("JapanesePokemonCard JSON") {
|
||||
TEST_CASE("uses 'setNo' and 'firstEdition' aliases") {
|
||||
JapanesePokemonCard c;
|
||||
|
||||
+4
-3
@@ -5,8 +5,8 @@
|
||||
## Layer pointers
|
||||
|
||||
- `include/ccm/ui/AppContext.hpp` — the boundary type. A struct of references to shared core services + per-game modules and a `std::vector<IGameView*>` of all UI bundles. UI code talks to core only through this struct (and the typed pointers go through `IGameView`, never directly).
|
||||
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types.
|
||||
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), toolbar (Add / Edit / Delete + filter input), and the splitter that swaps the active `IGameView`'s panels. The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
|
||||
- `include/ccm/ui/IGameView.hpp` — abstract base class for per-game UI bundles. `MainFrame` only ever sees `IGameView` references; this is the seam that lets the frame swap between Magic, Pokemon, and any future TCG without knowing their card types. Optional `contentPanel` / `hostsOwnLayout` / `contentPanelIfCreated` let Digimon own a tabbed layout without changing other games’ splitter mounting.
|
||||
- `include/ccm/ui/MainFrame.hpp` + `src/MainFrame.cpp` — top-level window (default size `1210×770`), menu strip (`File` / `Game` / `Sets` / `Help`), shared toolbar (Add / Edit / Delete + filter input; hidden for Digimon via `toolbarPanel_` when `hostsOwnLayout()`), and a `contentHost_` that either shows the shared splitter (Magic / Pokémon / Yu-Gi-Oh!) or a game’s `IGameView::contentPanel` (Digimon Digi-Battle notebook). The `Game` and `Sets` menus are built dynamically from `AppContext::gameViews` so adding a new game lights up its menu entries automatically. Filter and toolbar actions forward to `activeView()`. `EVT_PREVIEW_STATUS` (preview fetch outcome → status label; empty string resets to `"Ready"`) is the only event the frame binds; `EVT_CARD_SELECTED` is bound *per view* (each `IGameView` connects its typed list panel to its typed selected panel internally). About is a custom themed dialog (not `wxAboutBox`) so dark mode behavior stays consistent.
|
||||
- `include/ccm/ui/BaseCardListPanel.hpp` — header-only template `BaseCardListPanel<TCard, TSortColumn>` that owns ALL the non-game-specific `wxListCtrl` machinery: hidden zero-width spacer column (legacy of the MSW comctl32 image-list gutter workaround, kept to preserve column-index math), themed header row (clickable to sort, edge-drag to resize, divider double-click to autosize), per-icon-column cached `wxBitmap` pairs (normal + selected color) consumed by `IconListCtrl::MSWOnNotify` so row icons are pixel-perfect centered under the themed-header icons, rebuild guard so DESELECTED/SELECTED storms collapse into a single bubbled `EVT_CARD_SELECTED`, case-insensitive substring filter via `setFilter(...)`, per-column toggle-direction sort. Subclasses fill in column descriptors + per-row text + per-icon-column flag predicates + dispatch hooks (`sortBy`, `matchesFilter`).
|
||||
- `include/ccm/ui/IconListCtrl.hpp` + `src/IconListCtrl.cpp` — small `wxListCtrl` subclass that intercepts `NM_CUSTOMDRAW` on Windows and paints flag-icon sub-items at the exact center of each cell. It owns a `HIMAGELIST` (built from the cached `wxBitmap` pairs via straight-RGBA 32 bpp DIB sections) and draws each cell's icon with `ImageList_Draw(ILD_TRANSPARENT)` onto the native `HDC` from `NMLVCUSTOMDRAW`. This is the same low-level pixel path `wxImageList` uses internally, which is the only rendering path that has reliably preserved SVG transparency + correct fill color across light/dark themes on MSW. Two earlier attempts — `wxGraphicsContext::DrawBitmap` and a manually-premultiplied-DIB `AlphaBlend` — both rendered runtime-fill SVG icons as solid white in light mode and were abandoned (see convention 11). The custom-draw is purely about positioning; pixel format handling is delegated to comctl32.
|
||||
- `include/ccm/ui/BaseSelectedCardPanel.hpp` — header-only template `BaseSelectedCardPanel<TCard>` that owns the right-hand-side detail panel: preview image fetched via `CardPreviewService` (with the `shared_ptr<State>` + `std::atomic alive`/`currentGen` cancellation pattern), 2-column detail grid, flag-icon strip that collapses when no flags are set, image list with double-click viewer. If preview lookup fails or returns empty bytes, the panel loads a per-game **card-back fallback**: Magic and Pokémon West use fixed HTTPS URLs (`fallbackImageUrlForGame`, CCM2-aligned); **Pokémon Asia** uses the Japanese TCG back via `previewGameFor(card)` → `Game::JapanesePokemon`; **Yu-Gi-Oh!** tries Yugipedia thumbnail URL, then full `Back-EN.png` on `ms.yugipedia.com`, then reads `<exeDir>/assets/ygo_card_back.png`; **Digimon Digi-Battle** reads `<exeDir>/assets/digibattle99_card_back.png` (both bundled assets copied by `app/CMakeLists.txt` on link). The constructor caches `<exeDir>/` for that disk path. Subclasses describe the detail rows / flag icons / preview lookup `(name, setId, setNo)` and own a `Game` constant; override `previewGameFor` when preview routing differs from collection `gameId()` (Pokemon West/Asia).
|
||||
@@ -14,6 +14,7 @@
|
||||
- `include/ccm/ui/SwitchCtrl.hpp` + `src/SwitchCtrl.cpp` — custom pill-track + thumb switch for small modal rows (Yu-Gi-Oh! set picker); fires `EVT_CCM_SWITCH` on user toggle and reads colors from `inferThemeFromWindow` / `paletteForTheme`.
|
||||
- `include/ccm/ui/Magic*.hpp` + `src/Magic*.cpp` — Magic implementations: `MagicCardListPanel`, `MagicSelectedCardPanel`, `MagicCardEditDialog`, `MagicGameView`. Each is ~50–100 lines of hook overrides on top of the matching base template.
|
||||
- `include/ccm/ui/Pokemon*.hpp` + `src/Pokemon*.cpp` — Pokemon implementations: `PokemonCardListPanel`, `PokemonSelectedCardPanel`, `PokemonCardEditDialog`, `PokemonGameView`. Same shape as the Magic ones; differences are limited to the Set # field, the Holo / 1. Edition flags, and the Pokemon TCG preview lookup key (which includes `setNo`).
|
||||
- `include/ccm/ui/DigiBattle99*.hpp` + `src/DigiBattle99*.cpp` — Digimon Digi-Battle: list/selected/edit plus `DigiBattle99GameView` notebook (**Single Cards** | **Set Completion**) via `contentPanel`, and `DigiBattle99SetCompletionPanel` (pack progress tiles + greyed checklist). Catalog from `DigiBattle99SetCatalogService` (`set-catalog.json`), filled on Update Sets. The Add/Edit/Delete + filter toolbar lives **inside** the Single Cards tab (under the notebook); MainFrame hides its shared toolbar while Digimon is active (`hostsOwnLayout`).
|
||||
- `include/ccm/ui/SvgIcons.hpp` + `src/SvgIcons.cpp` — embedded SVG templates with a `@FILL@` placeholder. Magic flags: `kSvgFoil` / `kSvgSigned` / `kSvgAltered`. Pokemon flags: `kSvgHolo` (sparkle, mirroring the original `IconHolo` from `PokemonTable.tsx`) and `kSvgFirstEdition` (themed "1" inside an outlined badge, rebuilt from the original `IconPokemonFirstEdition.tsx` — every fill/stroke uses `@FILL@` so the icon themes alongside the others). Toolbar glyphs: `kSvgToolbarAdd` / `kSvgToolbarEdit` / `kSvgToolbarDelete` (vscode-codicons). `svgIconBitmap` / `paddedSvgIcon` helpers backed by `wxBitmapBundle::FromSVG`. Bitmaps from `svgIconBitmap` go straight to `wxStaticBitmap` / `wxBitmapButton::SetBitmap` cleanly; for the row-icon path `IconListCtrl` packs them into a private premultiplied-BGRA `HIMAGELIST` and draws with `ImageList_Draw`. See convention 11 for the full pitfall write-up.
|
||||
- `src/BaseEvents.cpp` — single-translation-unit definitions for `EVT_CARD_SELECTED` and `EVT_PREVIEW_STATUS`. Both events are template-instantiation-agnostic so all per-game panels share the same event types.
|
||||
- `include/ccm/ui/SettingsDialog.hpp` + `src/SettingsDialog.cpp` — edits `Configuration` via `ConfigService::store`.
|
||||
@@ -27,7 +28,7 @@
|
||||
3. **Ownership**: dialogs and panels are heap-allocated and parented to a `wxWindow`. wxWidgets owns the lifetime — do **not** wrap them in `unique_ptr`. `IGameView` instances themselves are owned by `app/main.cpp` (`std::unique_ptr<>`); the panels owned by the views become children of the `MainFrame` splitter on first mount.
|
||||
4. **Custom events**: `EVT_CARD_SELECTED` is fired by the list panel on itself (not its parent). Each `IGameView` binds it on its typed list panel inside the panel's first construction so the typed selection flows directly into the typed selected panel — `MainFrame` never sees a `MagicCard` or a `PokemonCard`. Do not move that binding back into `MainFrame`.
|
||||
5. **wxFont modifications** mutate in place: `font.MakeBold().MakeLarger()` — do not call `Scale` (it does not exist on wxFont 3.2; use `MakeLarger` / `SetPointSize`).
|
||||
6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the splitter swaps `listPanel()` / `selectedPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games.
|
||||
6. **Single-active-game UX.** `MainFrame` only ever shows one game's panels at a time; the content host swaps either the shared `listPanel()` / `selectedPanel()` splitter or a game’s `contentPanel()` when the user picks a different `Game` menu entry. Do not stand up parallel side-by-side tabs for different games. Digimon’s Single Cards / Set Completion notebook is an in-game mode switch, not multi-game tabs.
|
||||
7. **No `ccm_warnings`.** This target intentionally does **not** link the strict warning interface — wxWidgets headers trip `-Wpedantic` / `-Wshadow`. Keep it that way; do not add the link.
|
||||
8. **Async background work** must not capture `this` raw. Use the pattern from `BaseSelectedCardPanel`: a `std::shared_ptr<State>` holding `std::atomic<bool> alive`, `std::atomic<unsigned> currentGen`, and a back-pointer to the panel; spawn a detached `std::thread`, then deliver the result with `wxTheApp->CallAfter([state, gen, ...]() { if (!state->alive) return; if (state->currentGen != gen) return; ... })`. Flip `alive=false` in the panel destructor so late callbacks become no-ops.
|
||||
9. **Icons come from `SvgIcons.hpp`.** Don't inline new SVG strings in panel sources; add them to `SvgIcons.{hpp,cpp}` so all panels stay in sync. Always pass a runtime fill color (`wxSystemSettings::GetColour(...).GetAsString(wxC2S_HTML_SYNTAX)`); never bake one into the SVG.
|
||||
|
||||
@@ -23,6 +23,7 @@ add_library(ccm_ui_wx STATIC
|
||||
src/DigiBattle99SelectedCardPanel.cpp
|
||||
src/DigiBattle99CardEditDialog.cpp
|
||||
src/DigiBattle99GameView.cpp
|
||||
src/DigiBattle99SetCompletionPanel.cpp
|
||||
|
||||
src/SettingsDialog.cpp
|
||||
src/SwitchCtrl.cpp
|
||||
|
||||
@@ -5,18 +5,27 @@
|
||||
#include "ccm/services/CardPreviewService.hpp"
|
||||
#include "ccm/services/CollectionService.hpp"
|
||||
#include "ccm/services/ConfigService.hpp"
|
||||
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
|
||||
#include "ccm/services/ImageService.hpp"
|
||||
#include "ccm/services/SetService.hpp"
|
||||
#include "ccm/ui/IGameView.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
class wxBitmapButton;
|
||||
class wxBoxSizer;
|
||||
class wxNotebook;
|
||||
class wxSplitterWindow;
|
||||
class wxTextCtrl;
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
class DigiBattle99CardListPanel;
|
||||
class DigiBattle99SelectedCardPanel;
|
||||
class DigiBattle99SetCompletionPanel;
|
||||
|
||||
class DigiBattle99GameView final : public IGameView {
|
||||
public:
|
||||
@@ -25,13 +34,19 @@ public:
|
||||
SetService& sets,
|
||||
ImageService& images,
|
||||
CardPreviewService& cardPreview,
|
||||
IGameModule& module);
|
||||
IGameModule& module,
|
||||
DigiBattle99SetCatalogService& catalogStore);
|
||||
|
||||
[[nodiscard]] Game gameId() const noexcept override { return Game::DigiBattle99; }
|
||||
[[nodiscard]] std::string displayName() const override { return "Digimon (Digi-Battle)"; }
|
||||
|
||||
wxPanel* listPanel(wxWindow* parent) override;
|
||||
wxPanel* selectedPanel(wxWindow* parent) override;
|
||||
wxPanel* contentPanel(wxWindow* parent) override;
|
||||
[[nodiscard]] wxPanel* contentPanelIfCreated() const noexcept override {
|
||||
return contentPanel_;
|
||||
}
|
||||
[[nodiscard]] bool hostsOwnLayout() const noexcept override { return true; }
|
||||
|
||||
void refreshCollection() override;
|
||||
void onAddCard(wxWindow* parentWindow) override;
|
||||
@@ -47,6 +62,9 @@ public:
|
||||
private:
|
||||
void ensureSetsLoaded();
|
||||
const std::vector<Set>& setsForDialog();
|
||||
void ensureSingleCardsMounted(wxWindow* splitterParent);
|
||||
void buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer);
|
||||
void refreshToolbarIcons(const ThemePalette& palette);
|
||||
|
||||
ConfigService& config_;
|
||||
CollectionService<DigiBattle99Card>& collection_;
|
||||
@@ -54,9 +72,16 @@ private:
|
||||
ImageService& images_;
|
||||
CardPreviewService& cardPreview_;
|
||||
IGameModule& module_;
|
||||
DigiBattle99SetCatalogService& catalogStore_;
|
||||
|
||||
wxPanel* contentPanel_{nullptr};
|
||||
wxNotebook* notebook_{nullptr};
|
||||
wxSplitterWindow* singleSplitter_{nullptr};
|
||||
DigiBattle99CardListPanel* listPanel_{nullptr};
|
||||
DigiBattle99SelectedCardPanel* selectedPanel_{nullptr};
|
||||
DigiBattle99SetCompletionPanel* setCompletionPanel_{nullptr};
|
||||
std::array<wxBitmapButton*, 3> toolbarButtons_{{nullptr, nullptr, nullptr}};
|
||||
wxTextCtrl* filterInput_{nullptr};
|
||||
std::vector<Set> setsCache_;
|
||||
bool attemptedInitialSetLoad_{false};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
// DigiBattle99SetCompletionPanel: Set Completion tab — pack tiles with
|
||||
// progress bars for sets the user owns ≥1 card of, plus an in-tab checklist
|
||||
// drill-down (unowned rows greyed). Catalog is offline (set-catalog.json).
|
||||
|
||||
#include "ccm/domain/DigiBattle99Card.hpp"
|
||||
#include "ccm/domain/DigiBattle99SetCatalog.hpp"
|
||||
#include "ccm/services/DigiBattle99SetCatalogService.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/panel.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class wxBoxSizer;
|
||||
class wxListCtrl;
|
||||
class wxScrolledWindow;
|
||||
class wxSimplebook;
|
||||
class wxStaticText;
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
class DigiBattle99SetCompletionPanel : public wxPanel {
|
||||
public:
|
||||
DigiBattle99SetCompletionPanel(wxWindow* parent, DigiBattle99SetCatalogService& catalogStore);
|
||||
|
||||
void setCollection(std::vector<DigiBattle99Card> cards);
|
||||
void reloadFromStore();
|
||||
void applyTheme(const ThemePalette& palette);
|
||||
|
||||
private:
|
||||
void showGridPage();
|
||||
void showChecklistPage(const std::string& setId, const std::string& setName);
|
||||
void rebuildGrid();
|
||||
void rebuildChecklist(const std::string& setId);
|
||||
void setEmptyMessage(const wxString& message);
|
||||
void clearGridTiles();
|
||||
|
||||
DigiBattle99SetCatalogService& catalogStore_;
|
||||
DigiBattle99SetCatalog catalog_;
|
||||
bool catalogLoaded_{false};
|
||||
std::vector<DigiBattle99Card> collection_;
|
||||
ThemePalette palette_{};
|
||||
|
||||
wxSimplebook* book_{nullptr};
|
||||
wxPanel* gridPage_{nullptr};
|
||||
wxScrolledWindow* scroll_{nullptr};
|
||||
wxBoxSizer* gridSizer_{nullptr};
|
||||
wxStaticText* emptyLabel_{nullptr};
|
||||
|
||||
wxPanel* detailPage_{nullptr};
|
||||
wxStaticText* detailTitle_{nullptr};
|
||||
wxListCtrl* checklist_{nullptr};
|
||||
std::string detailSetId_;
|
||||
};
|
||||
|
||||
} // namespace ccm::ui
|
||||
@@ -35,6 +35,24 @@ public:
|
||||
virtual wxPanel* listPanel(wxWindow* parent) = 0;
|
||||
virtual wxPanel* selectedPanel(wxWindow* parent) = 0;
|
||||
|
||||
// When non-null, MainFrame mounts this as the sole content under the
|
||||
// toolbar instead of the shared selected|list splitter. Digimon uses this
|
||||
// for its Single Cards / Set Completion notebook. Default: no custom host.
|
||||
virtual wxPanel* contentPanel(wxWindow* parent) {
|
||||
(void)parent;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Non-constructing accessor so MainFrame can hide a previously mounted
|
||||
// content panel without forcing lazy creation for inactive games.
|
||||
[[nodiscard]] virtual wxPanel* contentPanelIfCreated() const noexcept {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Games that own their layout via contentPanel must not have their
|
||||
// list/selected panels parented onto MainFrame's shared splitter.
|
||||
[[nodiscard]] virtual bool hostsOwnLayout() const noexcept { return false; }
|
||||
|
||||
// Reload the active collection from disk and refresh the panels. The
|
||||
// selected card is preserved when possible.
|
||||
virtual void refreshCollection() = 0;
|
||||
|
||||
@@ -59,6 +59,8 @@ private:
|
||||
Game activeGame_{Game::Magic};
|
||||
|
||||
wxSplitterWindow* splitter_{nullptr};
|
||||
wxPanel* contentHost_{nullptr};
|
||||
wxPanel* toolbarPanel_{nullptr};
|
||||
wxTextCtrl* filterInput_{nullptr};
|
||||
wxPanel* menuStrip_{nullptr};
|
||||
wxStaticText* statusText_{nullptr};
|
||||
|
||||
@@ -1,31 +1,45 @@
|
||||
#include "ccm/ui/DigiBattle99GameView.hpp"
|
||||
|
||||
#include "ccm/games/digibattle99/DigiBattle99SetSource.hpp"
|
||||
#include "ccm/ui/CardEditModalGuard.hpp"
|
||||
#include "ccm/ui/DigiBattle99CardEditDialog.hpp"
|
||||
#include "ccm/ui/DigiBattle99CardListPanel.hpp"
|
||||
#include "ccm/ui/DigiBattle99SelectedCardPanel.hpp"
|
||||
#include "ccm/ui/DigiBattle99SetCompletionPanel.hpp"
|
||||
#include "ccm/ui/SvgIcons.hpp"
|
||||
#include "ccm/ui/Theme.hpp"
|
||||
|
||||
#include <wx/msgdlg.h>
|
||||
#include <wx/bmpbuttn.h>
|
||||
#include <wx/notebook.h>
|
||||
#include <wx/panel.h>
|
||||
#include <wx/sizer.h>
|
||||
#include <wx/splitter.h>
|
||||
#include <wx/textctrl.h>
|
||||
#include <wx/window.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
namespace {
|
||||
constexpr int kDigiToolbarIconPx = 18;
|
||||
constexpr const char kDigiFilterHint[] = "Filter";
|
||||
} // namespace
|
||||
|
||||
DigiBattle99GameView::DigiBattle99GameView(ConfigService& config,
|
||||
CollectionService<DigiBattle99Card>& collection,
|
||||
SetService& sets,
|
||||
ImageService& images,
|
||||
CardPreviewService& cardPreview,
|
||||
IGameModule& module)
|
||||
IGameModule& module,
|
||||
DigiBattle99SetCatalogService& catalogStore)
|
||||
: config_(config),
|
||||
collection_(collection),
|
||||
sets_(sets),
|
||||
images_(images),
|
||||
cardPreview_(cardPreview),
|
||||
module_(module) {}
|
||||
module_(module),
|
||||
catalogStore_(catalogStore) {}
|
||||
|
||||
void DigiBattle99GameView::ensureSetsLoaded() {
|
||||
if (attemptedInitialSetLoad_) return;
|
||||
@@ -45,6 +59,101 @@ void DigiBattle99GameView::ensureSetsLoaded() {
|
||||
}
|
||||
}
|
||||
|
||||
void DigiBattle99GameView::ensureSingleCardsMounted(wxWindow* splitterParent) {
|
||||
if (singleSplitter_ == nullptr) {
|
||||
singleSplitter_ = new wxSplitterWindow(splitterParent, wxID_ANY, wxDefaultPosition,
|
||||
wxDefaultSize, wxSP_LIVE_UPDATE);
|
||||
singleSplitter_->SetMinimumPaneSize(280);
|
||||
}
|
||||
auto* list = listPanel(singleSplitter_);
|
||||
auto* selected = selectedPanel(singleSplitter_);
|
||||
if (!singleSplitter_->IsSplit()) {
|
||||
singleSplitter_->SplitVertically(selected, list, 360);
|
||||
}
|
||||
}
|
||||
|
||||
void DigiBattle99GameView::buildSingleCardsToolbar(wxWindow* parent, wxBoxSizer* pageSizer) {
|
||||
auto* toolbar = new wxBoxSizer(wxHORIZONTAL);
|
||||
auto makeToolBtn = [&](const char* svg, const wxString& tip) {
|
||||
wxBitmap bmp = svgIconBitmap(svg, kDigiToolbarIconPx, "#000000");
|
||||
auto* b = new wxBitmapButton(parent, wxID_ANY, bmp, wxDefaultPosition, wxDefaultSize,
|
||||
wxBU_EXACTFIT);
|
||||
b->SetToolTip(tip);
|
||||
return b;
|
||||
};
|
||||
toolbarButtons_[0] = makeToolBtn(kSvgToolbarAdd, "Add Card");
|
||||
toolbarButtons_[1] = makeToolBtn(kSvgToolbarEdit, "Edit");
|
||||
toolbarButtons_[2] = makeToolBtn(kSvgToolbarDelete, "Delete");
|
||||
toolbar->AddSpacer(4);
|
||||
toolbar->Add(toolbarButtons_[0], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
|
||||
toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
|
||||
toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
|
||||
toolbar->AddStretchSpacer(1);
|
||||
filterInput_ = new wxTextCtrl(parent, wxID_ANY, "", wxDefaultPosition, wxSize(260, -1));
|
||||
filterInput_->SetHint(kDigiFilterHint);
|
||||
toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4);
|
||||
pageSizer->Add(toolbar, 0, wxEXPAND);
|
||||
|
||||
toolbarButtons_[0]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
|
||||
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
|
||||
onAddCard(owner != nullptr ? owner : contentPanel_);
|
||||
});
|
||||
toolbarButtons_[1]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
|
||||
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
|
||||
onEditCard(owner != nullptr ? owner : contentPanel_);
|
||||
});
|
||||
toolbarButtons_[2]->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) {
|
||||
wxWindow* owner = wxGetTopLevelParent(contentPanel_);
|
||||
onDeleteCard(owner != nullptr ? owner : contentPanel_);
|
||||
});
|
||||
filterInput_->Bind(wxEVT_TEXT, [this](wxCommandEvent&) {
|
||||
if (filterInput_ == nullptr) return;
|
||||
setFilter(filterInput_->GetValue().ToStdString(wxConvUTF8));
|
||||
});
|
||||
}
|
||||
|
||||
void DigiBattle99GameView::refreshToolbarIcons(const ThemePalette& palette) {
|
||||
const std::string tbHex = palette.buttonText.GetAsString(wxC2S_HTML_SYNTAX).ToStdString();
|
||||
if (toolbarButtons_[0]) {
|
||||
toolbarButtons_[0]->SetBitmap(
|
||||
svgIconBitmap(kSvgToolbarAdd, kDigiToolbarIconPx, tbHex.c_str()));
|
||||
}
|
||||
if (toolbarButtons_[1]) {
|
||||
toolbarButtons_[1]->SetBitmap(
|
||||
svgIconBitmap(kSvgToolbarEdit, kDigiToolbarIconPx, tbHex.c_str()));
|
||||
}
|
||||
if (toolbarButtons_[2]) {
|
||||
toolbarButtons_[2]->SetBitmap(
|
||||
svgIconBitmap(kSvgToolbarDelete, kDigiToolbarIconPx, tbHex.c_str()));
|
||||
}
|
||||
}
|
||||
|
||||
wxPanel* DigiBattle99GameView::contentPanel(wxWindow* parent) {
|
||||
if (contentPanel_ == nullptr) {
|
||||
contentPanel_ = new wxPanel(parent);
|
||||
auto* root = new wxBoxSizer(wxVERTICAL);
|
||||
|
||||
notebook_ = new wxNotebook(contentPanel_, wxID_ANY);
|
||||
auto* singlePage = new wxPanel(notebook_);
|
||||
auto* singleSizer = new wxBoxSizer(wxVERTICAL);
|
||||
buildSingleCardsToolbar(singlePage, singleSizer);
|
||||
ensureSingleCardsMounted(singlePage);
|
||||
singleSizer->Add(singleSplitter_, 1, wxEXPAND);
|
||||
singlePage->SetSizer(singleSizer);
|
||||
notebook_->AddPage(singlePage, "Single Cards");
|
||||
|
||||
setCompletionPanel_ = new DigiBattle99SetCompletionPanel(notebook_, catalogStore_);
|
||||
setCompletionPanel_->reloadFromStore();
|
||||
notebook_->AddPage(setCompletionPanel_, "Set Completion");
|
||||
|
||||
root->Add(notebook_, 1, wxEXPAND);
|
||||
contentPanel_->SetSizer(root);
|
||||
|
||||
refreshToolbarIcons(paletteForTheme(config_.current().theme));
|
||||
}
|
||||
return contentPanel_;
|
||||
}
|
||||
|
||||
wxPanel* DigiBattle99GameView::listPanel(wxWindow* parent) {
|
||||
if (listPanel_ == nullptr) {
|
||||
listPanel_ = new DigiBattle99CardListPanel(parent);
|
||||
@@ -69,7 +178,10 @@ wxPanel* DigiBattle99GameView::selectedPanel(wxWindow* parent) {
|
||||
}
|
||||
|
||||
void DigiBattle99GameView::refreshCollection() {
|
||||
if (listPanel_ == nullptr) return;
|
||||
// Ensure the Digimon host (and list panel) exist even when MainFrame mounts
|
||||
// via contentPanel before an explicit listPanel call.
|
||||
if (contentPanel_ == nullptr && listPanel_ == nullptr) return;
|
||||
|
||||
auto loaded = collection_.list(Game::DigiBattle99);
|
||||
if (!loaded) {
|
||||
showThemedMessageDialog(
|
||||
@@ -78,9 +190,15 @@ void DigiBattle99GameView::refreshCollection() {
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return;
|
||||
}
|
||||
listPanel_->setCards(std::move(loaded).value());
|
||||
listPanel_->activateSelection();
|
||||
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
|
||||
auto cards = std::move(loaded).value();
|
||||
if (listPanel_ != nullptr) {
|
||||
listPanel_->setCards(cards);
|
||||
listPanel_->activateSelection();
|
||||
if (selectedPanel_) selectedPanel_->setCard(listPanel_->selected());
|
||||
}
|
||||
if (setCompletionPanel_ != nullptr) {
|
||||
setCompletionPanel_->setCollection(std::move(cards));
|
||||
}
|
||||
}
|
||||
|
||||
const std::vector<Set>& DigiBattle99GameView::setsForDialog() {
|
||||
@@ -190,27 +308,79 @@ void DigiBattle99GameView::onDeleteCard(wxWindow* parentWindow) {
|
||||
}
|
||||
|
||||
std::string DigiBattle99GameView::onUpdateSets(wxWindow* parentWindow) {
|
||||
auto out = sets_.updateSets(Game::DigiBattle99);
|
||||
if (!out) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to update sets: " + out.error(),
|
||||
auto* digiSrc = dynamic_cast<DigiBattle99SetSource*>(&module_.setSource());
|
||||
if (digiSrc == nullptr) {
|
||||
showThemedMessageDialog(parentWindow, "Digimon Digi-Battle set source unavailable.",
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return "Update failed";
|
||||
}
|
||||
setsCache_ = out.value();
|
||||
|
||||
auto both = digiSrc->fetchAllWithCatalog();
|
||||
if (!both) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to update sets: " + both.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return "Update failed";
|
||||
}
|
||||
|
||||
auto savedSets = sets_.saveSets(Game::DigiBattle99, both.value().sets);
|
||||
if (!savedSets) {
|
||||
showThemedMessageDialog(parentWindow, "Failed to save sets: " + savedSets.error(),
|
||||
"Error", wxOK | wxICON_ERROR);
|
||||
return "Update failed";
|
||||
}
|
||||
|
||||
auto savedCatalog = catalogStore_.save(both.value().catalog);
|
||||
if (!savedCatalog) {
|
||||
showThemedMessageDialog(parentWindow,
|
||||
"Sets saved, but set catalog failed: " + savedCatalog.error(),
|
||||
"Warning", wxOK | wxICON_WARNING);
|
||||
}
|
||||
|
||||
setsCache_ = both.value().sets;
|
||||
if (setCompletionPanel_ != nullptr) {
|
||||
setCompletionPanel_->reloadFromStore();
|
||||
if (auto loaded = collection_.list(Game::DigiBattle99)) {
|
||||
setCompletionPanel_->setCollection(std::move(loaded).value());
|
||||
}
|
||||
}
|
||||
|
||||
const std::size_t setCount = both.value().sets.size();
|
||||
const std::size_t packCount = both.value().catalog.packs.size();
|
||||
showThemedMessageDialog(
|
||||
parentWindow,
|
||||
"Updated " + std::to_string(out.value().size()) + " Digimon (Digi-Battle) sets.",
|
||||
"Updated " + std::to_string(setCount) + " Digimon (Digi-Battle) sets and " +
|
||||
std::to_string(packCount) + " set checklists.",
|
||||
"Sets updated", wxOK | wxICON_INFORMATION);
|
||||
return "Digimon (Digi-Battle) sets updated.";
|
||||
}
|
||||
|
||||
void DigiBattle99GameView::setFilter(std::string_view filter) {
|
||||
if (filterInput_ != nullptr) {
|
||||
const wxString wanted = wxString::FromUTF8(std::string(filter).c_str());
|
||||
if (filterInput_->GetValue() != wanted) {
|
||||
filterInput_->ChangeValue(wanted);
|
||||
if (filter.empty()) {
|
||||
filterInput_->SetHint(kDigiFilterHint);
|
||||
filterInput_->Refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (listPanel_) listPanel_->setFilter(filter);
|
||||
}
|
||||
|
||||
void DigiBattle99GameView::applyTheme(const ThemePalette& palette) {
|
||||
if (contentPanel_) applyThemeToWindowTree(contentPanel_, palette, config_.current().theme);
|
||||
if (listPanel_) listPanel_->applyTheme(palette);
|
||||
if (selectedPanel_) selectedPanel_->applyTheme(palette);
|
||||
if (setCompletionPanel_) setCompletionPanel_->applyTheme(palette);
|
||||
refreshToolbarIcons(palette);
|
||||
if (filterInput_ != nullptr) {
|
||||
filterInput_->SetBackgroundColour(palette.inputBg);
|
||||
filterInput_->SetForegroundColour(palette.inputText);
|
||||
filterInput_->SetOwnBackgroundColour(palette.inputBg);
|
||||
filterInput_->SetOwnForegroundColour(palette.inputText);
|
||||
filterInput_->Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
#include "ccm/ui/DigiBattle99SetCompletionPanel.hpp"
|
||||
|
||||
#include "ccm/services/DigiBattle99SetCompletion.hpp"
|
||||
|
||||
#include <wx/button.h>
|
||||
#include <wx/cursor.h>
|
||||
#include <wx/gauge.h>
|
||||
#include <wx/listctrl.h>
|
||||
#include <wx/scrolwin.h>
|
||||
#include <wx/simplebook.h>
|
||||
#include <wx/sizer.h>
|
||||
#include <wx/stattext.h>
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace ccm::ui {
|
||||
|
||||
namespace {
|
||||
|
||||
wxColour mutedTextColour(const ThemePalette& palette) {
|
||||
// Blend text toward panel background so missing checklist rows read as greyed.
|
||||
const auto blend = [](unsigned char a, unsigned char b) -> unsigned char {
|
||||
return static_cast<unsigned char>((static_cast<int>(a) * 2 + static_cast<int>(b)) / 3);
|
||||
};
|
||||
return wxColour(blend(palette.text.Red(), palette.panelBg.Red()),
|
||||
blend(palette.text.Green(), palette.panelBg.Green()),
|
||||
blend(palette.text.Blue(), palette.panelBg.Blue()));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DigiBattle99SetCompletionPanel::DigiBattle99SetCompletionPanel(
|
||||
wxWindow* parent, DigiBattle99SetCatalogService& catalogStore)
|
||||
: wxPanel(parent), catalogStore_(catalogStore) {
|
||||
palette_ = paletteForTheme(inferThemeFromWindow(this));
|
||||
|
||||
book_ = new wxSimplebook(this, wxID_ANY);
|
||||
|
||||
gridPage_ = new wxPanel(book_);
|
||||
auto* gridRoot = new wxBoxSizer(wxVERTICAL);
|
||||
emptyLabel_ = new wxStaticText(gridPage_, wxID_ANY, "");
|
||||
emptyLabel_->Wrap(480);
|
||||
gridRoot->Add(emptyLabel_, 0, wxALL | wxEXPAND, 12);
|
||||
|
||||
scroll_ = new wxScrolledWindow(gridPage_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
|
||||
wxVSCROLL | wxTAB_TRAVERSAL);
|
||||
scroll_->SetScrollRate(0, 16);
|
||||
gridSizer_ = new wxBoxSizer(wxVERTICAL);
|
||||
scroll_->SetSizer(gridSizer_);
|
||||
gridRoot->Add(scroll_, 1, wxEXPAND);
|
||||
gridPage_->SetSizer(gridRoot);
|
||||
book_->AddPage(gridPage_, "Grid");
|
||||
|
||||
detailPage_ = new wxPanel(book_);
|
||||
auto* detailRoot = new wxBoxSizer(wxVERTICAL);
|
||||
auto* topRow = new wxBoxSizer(wxHORIZONTAL);
|
||||
auto* backBtn = new wxButton(detailPage_, wxID_ANY, "Back");
|
||||
backBtn->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { showGridPage(); });
|
||||
detailTitle_ = new wxStaticText(detailPage_, wxID_ANY, "");
|
||||
auto titleFont = detailTitle_->GetFont();
|
||||
titleFont.MakeBold().MakeLarger();
|
||||
detailTitle_->SetFont(titleFont);
|
||||
topRow->Add(backBtn, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT, 8);
|
||||
topRow->Add(detailTitle_, 1, wxALIGN_CENTER_VERTICAL);
|
||||
detailRoot->Add(topRow, 0, wxEXPAND | wxALL, 8);
|
||||
|
||||
checklist_ = new wxListCtrl(detailPage_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
|
||||
wxLC_REPORT | wxLC_SINGLE_SEL | wxLC_NO_HEADER);
|
||||
checklist_->AppendColumn("Card", wxLIST_FORMAT_LEFT, 520);
|
||||
detailRoot->Add(checklist_, 1, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 8);
|
||||
detailPage_->SetSizer(detailRoot);
|
||||
book_->AddPage(detailPage_, "Detail");
|
||||
|
||||
auto* root = new wxBoxSizer(wxVERTICAL);
|
||||
root->Add(book_, 1, wxEXPAND);
|
||||
SetSizer(root);
|
||||
|
||||
showGridPage();
|
||||
}
|
||||
|
||||
void DigiBattle99SetCompletionPanel::setCollection(std::vector<DigiBattle99Card> cards) {
|
||||
collection_ = std::move(cards);
|
||||
if (book_->GetSelection() == 1 && !detailSetId_.empty()) {
|
||||
rebuildChecklist(detailSetId_);
|
||||
} else {
|
||||
rebuildGrid();
|
||||
}
|
||||
}
|
||||
|
||||
void DigiBattle99SetCompletionPanel::reloadFromStore() {
|
||||
catalogLoaded_ = false;
|
||||
catalog_ = {};
|
||||
if (catalogStore_.exists()) {
|
||||
if (auto loaded = catalogStore_.load()) {
|
||||
catalog_ = std::move(loaded).value();
|
||||
catalogLoaded_ = true;
|
||||
}
|
||||
}
|
||||
showGridPage();
|
||||
rebuildGrid();
|
||||
}
|
||||
|
||||
void DigiBattle99SetCompletionPanel::applyTheme(const ThemePalette& palette) {
|
||||
palette_ = palette;
|
||||
applyThemeToWindowTree(this, palette, inferThemeFromWindow(this));
|
||||
if (book_->GetSelection() == 1 && !detailSetId_.empty()) {
|
||||
rebuildChecklist(detailSetId_);
|
||||
} else {
|
||||
rebuildGrid();
|
||||
}
|
||||
}
|
||||
|
||||
void DigiBattle99SetCompletionPanel::showGridPage() {
|
||||
detailSetId_.clear();
|
||||
book_->SetSelection(0);
|
||||
}
|
||||
|
||||
void DigiBattle99SetCompletionPanel::showChecklistPage(const std::string& setId,
|
||||
const std::string& setName) {
|
||||
detailSetId_ = setId;
|
||||
detailTitle_->SetLabelText(wxString::FromUTF8(setName.c_str()));
|
||||
rebuildChecklist(setId);
|
||||
book_->SetSelection(1);
|
||||
}
|
||||
|
||||
void DigiBattle99SetCompletionPanel::setEmptyMessage(const wxString& message) {
|
||||
clearGridTiles();
|
||||
emptyLabel_->SetLabelText(message);
|
||||
emptyLabel_->Wrap(480);
|
||||
emptyLabel_->Show();
|
||||
scroll_->Hide();
|
||||
gridPage_->Layout();
|
||||
}
|
||||
|
||||
void DigiBattle99SetCompletionPanel::clearGridTiles() {
|
||||
if (gridSizer_ == nullptr) return;
|
||||
gridSizer_->Clear(true);
|
||||
}
|
||||
|
||||
void DigiBattle99SetCompletionPanel::rebuildGrid() {
|
||||
if (!catalogLoaded_) {
|
||||
setEmptyMessage(wxString::FromUTF8(
|
||||
"Set checklists are not downloaded yet.\n"
|
||||
"Run Sets → Update Digimon (Digi-Battle) to enable Set Completion."));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto rows = computeDigiBattle99SetCompletion(collection_, catalog_);
|
||||
if (rows.empty()) {
|
||||
setEmptyMessage(wxString::FromUTF8(
|
||||
"No Digimon (Digi-Battle) sets in progress yet.\n"
|
||||
"Add cards on the Single Cards tab to track set completion here."));
|
||||
return;
|
||||
}
|
||||
|
||||
emptyLabel_->Hide();
|
||||
scroll_->Show();
|
||||
clearGridTiles();
|
||||
|
||||
for (const auto& row : rows) {
|
||||
auto* tile = new wxPanel(scroll_, wxID_ANY, wxDefaultPosition, wxDefaultSize,
|
||||
wxBORDER_SIMPLE);
|
||||
tile->SetBackgroundColour(palette_.panelBg);
|
||||
auto* tileSizer = new wxBoxSizer(wxVERTICAL);
|
||||
|
||||
auto* nameLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(row.setName.c_str()));
|
||||
auto nameFont = nameLbl->GetFont();
|
||||
nameFont.MakeBold();
|
||||
nameLbl->SetFont(nameFont);
|
||||
nameLbl->SetForegroundColour(palette_.text);
|
||||
|
||||
const std::string counts =
|
||||
std::to_string(row.ownedUnique) + " / " + std::to_string(row.total) + " (" +
|
||||
std::to_string(row.percent()) + "%)";
|
||||
auto* countLbl = new wxStaticText(tile, wxID_ANY, wxString::FromUTF8(counts.c_str()));
|
||||
countLbl->SetForegroundColour(palette_.text);
|
||||
|
||||
auto* gauge = new wxGauge(tile, wxID_ANY, 100, wxDefaultPosition, wxSize(-1, 14),
|
||||
wxGA_HORIZONTAL | wxGA_SMOOTH);
|
||||
gauge->SetValue(row.percent());
|
||||
|
||||
tileSizer->Add(nameLbl, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 10);
|
||||
tileSizer->Add(countLbl, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 6);
|
||||
tileSizer->Add(gauge, 0, wxEXPAND | wxALL, 10);
|
||||
tile->SetSizer(tileSizer);
|
||||
|
||||
const std::string setId = row.setId;
|
||||
const std::string setName = row.setName;
|
||||
auto openDetail = [this, setId, setName](wxMouseEvent&) {
|
||||
showChecklistPage(setId, setName);
|
||||
};
|
||||
tile->Bind(wxEVT_LEFT_UP, openDetail);
|
||||
nameLbl->Bind(wxEVT_LEFT_UP, openDetail);
|
||||
countLbl->Bind(wxEVT_LEFT_UP, openDetail);
|
||||
gauge->Bind(wxEVT_LEFT_UP, openDetail);
|
||||
tile->SetCursor(wxCursor(wxCURSOR_HAND));
|
||||
nameLbl->SetCursor(wxCursor(wxCURSOR_HAND));
|
||||
countLbl->SetCursor(wxCursor(wxCURSOR_HAND));
|
||||
|
||||
gridSizer_->Add(tile, 0, wxEXPAND | wxLEFT | wxRIGHT | wxTOP, 8);
|
||||
}
|
||||
gridSizer_->AddStretchSpacer(1);
|
||||
scroll_->FitInside();
|
||||
gridPage_->Layout();
|
||||
Layout();
|
||||
}
|
||||
|
||||
void DigiBattle99SetCompletionPanel::rebuildChecklist(const std::string& setId) {
|
||||
checklist_->DeleteAllItems();
|
||||
const auto entries = digiBattle99ChecklistForSet(collection_, catalog_, setId);
|
||||
const wxColour muted = mutedTextColour(palette_);
|
||||
|
||||
long idx = 0;
|
||||
for (const auto& entry : entries) {
|
||||
const std::string line = entry.setNo + " — " + entry.name;
|
||||
const long row = checklist_->InsertItem(idx++, wxString::FromUTF8(line.c_str()));
|
||||
if (row < 0) continue;
|
||||
if (entry.owned) {
|
||||
checklist_->SetItemTextColour(row, palette_.text);
|
||||
} else {
|
||||
checklist_->SetItemTextColour(row, muted);
|
||||
}
|
||||
}
|
||||
checklist_->SetColumnWidth(0, wxLIST_AUTOSIZE);
|
||||
detailPage_->Layout();
|
||||
}
|
||||
|
||||
} // namespace ccm::ui
|
||||
+51
-7
@@ -133,10 +133,11 @@ void MainFrame::buildLayout() {
|
||||
menuStrip_->SetSizer(menuSizer);
|
||||
root->Add(menuStrip_, 0, wxEXPAND);
|
||||
|
||||
toolbarPanel_ = new wxPanel(this, wxID_ANY);
|
||||
auto* toolbar = new wxBoxSizer(wxHORIZONTAL);
|
||||
auto makeToolBtn = [&](int id, const char* svg, const wxString& tip) {
|
||||
wxBitmap bmp = svgIconBitmap(svg, kToolbarIconPx, "#000000");
|
||||
auto* b = new wxBitmapButton(this, id, bmp, wxDefaultPosition,
|
||||
auto* b = new wxBitmapButton(toolbarPanel_, id, bmp, wxDefaultPosition,
|
||||
wxDefaultSize,
|
||||
wxBU_EXACTFIT);
|
||||
b->SetToolTip(tip);
|
||||
@@ -150,16 +151,21 @@ void MainFrame::buildLayout() {
|
||||
toolbar->Add(toolbarButtons_[1], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
|
||||
toolbar->Add(toolbarButtons_[2], 0, wxALIGN_CENTER_VERTICAL | wxALL, 4);
|
||||
toolbar->AddStretchSpacer(1);
|
||||
filterInput_ = new wxTextCtrl(this, wxID_ANY, "", wxDefaultPosition,
|
||||
filterInput_ = new wxTextCtrl(toolbarPanel_, wxID_ANY, "", wxDefaultPosition,
|
||||
wxSize(260, -1));
|
||||
filterInput_->SetHint(kFilterInputHint);
|
||||
toolbar->Add(filterInput_, 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxTOP | wxBOTTOM, 4);
|
||||
root->Add(toolbar, 0, wxEXPAND);
|
||||
toolbarPanel_->SetSizer(toolbar);
|
||||
root->Add(toolbarPanel_, 0, wxEXPAND);
|
||||
|
||||
splitter_ = new wxSplitterWindow(this, wxID_ANY, wxDefaultPosition,
|
||||
contentHost_ = new wxPanel(this, wxID_ANY);
|
||||
auto* hostSizer = new wxBoxSizer(wxVERTICAL);
|
||||
splitter_ = new wxSplitterWindow(contentHost_, wxID_ANY, wxDefaultPosition,
|
||||
wxDefaultSize, wxSP_LIVE_UPDATE);
|
||||
splitter_->SetMinimumPaneSize(280);
|
||||
root->Add(splitter_, 1, wxEXPAND);
|
||||
hostSizer->Add(splitter_, 1, wxEXPAND);
|
||||
contentHost_->SetSizer(hostSizer);
|
||||
root->Add(contentHost_, 1, wxEXPAND);
|
||||
|
||||
auto* statusPanel = new wxPanel(this, wxID_ANY);
|
||||
auto* statusSizer = new wxBoxSizer(wxHORIZONTAL);
|
||||
@@ -199,15 +205,53 @@ IGameView* MainFrame::activeView() {
|
||||
|
||||
void MainFrame::mountActiveView() {
|
||||
auto* view = activeView();
|
||||
if (view == nullptr || splitter_ == nullptr) return;
|
||||
if (view == nullptr || splitter_ == nullptr || contentHost_ == nullptr) return;
|
||||
|
||||
auto* hostSizer = contentHost_->GetSizer();
|
||||
if (hostSizer == nullptr) return;
|
||||
|
||||
// Hide every other view's panels so wx doesn't double-paint them.
|
||||
for (auto* other : ctx_.gameViews) {
|
||||
if (other == nullptr || other == view) continue;
|
||||
if (other->hostsOwnLayout()) {
|
||||
if (auto* cp = other->contentPanelIfCreated()) cp->Hide();
|
||||
continue;
|
||||
}
|
||||
if (auto* lp = other->listPanel(splitter_)) lp->Hide();
|
||||
if (auto* sp = other->selectedPanel(splitter_)) sp->Hide();
|
||||
}
|
||||
|
||||
const ThemePalette palette = paletteForTheme(ctx_.config.current().theme);
|
||||
|
||||
if (toolbarPanel_ != nullptr) {
|
||||
if (view->hostsOwnLayout()) toolbarPanel_->Hide();
|
||||
else toolbarPanel_->Show();
|
||||
Layout();
|
||||
}
|
||||
|
||||
if (view->hostsOwnLayout()) {
|
||||
auto* custom = view->contentPanel(contentHost_);
|
||||
if (custom == nullptr) return;
|
||||
|
||||
splitter_->Hide();
|
||||
hostSizer->Clear(false);
|
||||
custom->Show();
|
||||
hostSizer->Add(custom, 1, wxEXPAND);
|
||||
contentHost_->Layout();
|
||||
|
||||
view->applyTheme(palette);
|
||||
applyThemeToWindowTree(custom, palette, ctx_.config.current().theme);
|
||||
return;
|
||||
}
|
||||
|
||||
if (auto* previousCustom = view->contentPanelIfCreated()) {
|
||||
previousCustom->Hide();
|
||||
}
|
||||
// Re-seat the shared splitter if a contentPanel game was showing.
|
||||
hostSizer->Clear(false);
|
||||
splitter_->Show();
|
||||
hostSizer->Add(splitter_, 1, wxEXPAND);
|
||||
|
||||
auto* listPanel = view->listPanel(splitter_);
|
||||
auto* selectedPanel = view->selectedPanel(splitter_);
|
||||
if (listPanel == nullptr || selectedPanel == nullptr) return;
|
||||
@@ -221,7 +265,7 @@ void MainFrame::mountActiveView() {
|
||||
splitter_->SplitVertically(selectedPanel, listPanel, 360);
|
||||
}
|
||||
|
||||
const ThemePalette palette = paletteForTheme(ctx_.config.current().theme);
|
||||
contentHost_->Layout();
|
||||
view->applyTheme(palette);
|
||||
applyThemeToWindowTree(selectedPanel, palette, ctx_.config.current().theme);
|
||||
applyThemeToWindowTree(listPanel, palette, ctx_.config.current().theme);
|
||||
|
||||
Reference in New Issue
Block a user