diff --git a/Android/app/schemas/com.majinnaibu.monstercards.AppDatabase/4.json b/Android/app/schemas/com.majinnaibu.monstercards.AppDatabase/4.json new file mode 100644 index 0000000..b53e5a8 --- /dev/null +++ b/Android/app/schemas/com.majinnaibu.monstercards.AppDatabase/4.json @@ -0,0 +1,554 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "ca0ad042eed557daa5ff0ada5de6596c", + "entities": [ + { + "tableName": "monsters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `size` TEXT NOT NULL DEFAULT '', `type` TEXT NOT NULL DEFAULT '', `subtype` TEXT NOT NULL DEFAULT '', `alignment` TEXT NOT NULL DEFAULT '', `strength_score` INTEGER NOT NULL DEFAULT 10, `strength_saving_throw_advantage` TEXT DEFAULT 'none', `strength_saving_throw_proficiency` TEXT DEFAULT 'none', `dexterity_score` INTEGER NOT NULL DEFAULT 10, `dexterity_saving_throw_advantage` TEXT DEFAULT 'none', `dexterity_saving_throw_proficiency` TEXT DEFAULT 'none', `constitution_score` INTEGER NOT NULL DEFAULT 10, `constitution_saving_throw_advantage` TEXT DEFAULT 'none', `constitution_saving_throw_proficiency` TEXT DEFAULT 'none', `intelligence_score` INTEGER NOT NULL DEFAULT 10, `intelligence_saving_throw_advantage` TEXT DEFAULT 'none', `intelligence_saving_throw_proficiency` TEXT DEFAULT 'none', `wisdom_score` INTEGER NOT NULL DEFAULT 10, `wisdom_saving_throw_advantage` TEXT DEFAULT 'none', `wisdom_saving_throw_proficiency` TEXT DEFAULT 'none', `charisma_score` INTEGER NOT NULL DEFAULT 10, `charisma_saving_throw_advantage` TEXT DEFAULT 'none', `charisma_saving_throw_proficiency` TEXT DEFAULT 'none', `armor_type` TEXT DEFAULT 'none', `shield_bonus` INTEGER NOT NULL DEFAULT 0, `natural_armor_bonus` INTEGER NOT NULL DEFAULT 0, `other_armor_description` TEXT DEFAULT '', `hit_dice` INTEGER NOT NULL DEFAULT 1, `has_custom_hit_points` INTEGER NOT NULL, `custom_hit_points_description` TEXT DEFAULT '', `walk_speed` INTEGER NOT NULL DEFAULT 0, `burrow_speed` INTEGER NOT NULL DEFAULT 0, `climb_speed` INTEGER NOT NULL DEFAULT 0, `fly_speed` INTEGER NOT NULL DEFAULT 0, `can_hover` INTEGER NOT NULL DEFAULT false, `swim_speed` INTEGER NOT NULL DEFAULT 0, `has_custom_speed` INTEGER NOT NULL DEFAULT false, `custom_speed_description` TEXT, `challenge_rating` TEXT DEFAULT '1', `custom_challenge_rating_description` TEXT DEFAULT '', `custom_proficiency_bonus` INTEGER NOT NULL DEFAULT 0, `telepathy_range` INTEGER NOT NULL DEFAULT 0, `understands_but_description` TEXT DEFAULT '', `senses` TEXT DEFAULT '[]', `skills` TEXT DEFAULT '[]', `damage_immunities` TEXT DEFAULT '[]', `damage_resistances` TEXT DEFAULT '[]', `damage_vulnerabilities` TEXT DEFAULT '[]', `condition_immunities` TEXT DEFAULT '[]', `languages` TEXT DEFAULT '[]', `abilities` TEXT DEFAULT '[]', `actions` TEXT DEFAULT '[]', `reactions` TEXT DEFAULT '[]', `lair_actions` TEXT DEFAULT '[]', `legendary_actions` TEXT DEFAULT '[]', `regional_actions` TEXT DEFAULT '[]', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "subtype", + "columnName": "subtype", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "alignment", + "columnName": "alignment", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "strengthScore", + "columnName": "strength_score", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "10" + }, + { + "fieldPath": "strengthSavingThrowAdvantage", + "columnName": "strength_saving_throw_advantage", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "strengthSavingThrowProficiency", + "columnName": "strength_saving_throw_proficiency", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "dexterityScore", + "columnName": "dexterity_score", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "10" + }, + { + "fieldPath": "dexteritySavingThrowAdvantage", + "columnName": "dexterity_saving_throw_advantage", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "dexteritySavingThrowProficiency", + "columnName": "dexterity_saving_throw_proficiency", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "constitutionScore", + "columnName": "constitution_score", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "10" + }, + { + "fieldPath": "constitutionSavingThrowAdvantage", + "columnName": "constitution_saving_throw_advantage", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "constitutionSavingThrowProficiency", + "columnName": "constitution_saving_throw_proficiency", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "intelligenceScore", + "columnName": "intelligence_score", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "10" + }, + { + "fieldPath": "intelligenceSavingThrowAdvantage", + "columnName": "intelligence_saving_throw_advantage", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "intelligenceSavingThrowProficiency", + "columnName": "intelligence_saving_throw_proficiency", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "wisdomScore", + "columnName": "wisdom_score", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "10" + }, + { + "fieldPath": "wisdomSavingThrowAdvantage", + "columnName": "wisdom_saving_throw_advantage", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "wisdomSavingThrowProficiency", + "columnName": "wisdom_saving_throw_proficiency", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "charismaScore", + "columnName": "charisma_score", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "10" + }, + { + "fieldPath": "charismaSavingThrowAdvantage", + "columnName": "charisma_saving_throw_advantage", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "charismaSavingThrowProficiency", + "columnName": "charisma_saving_throw_proficiency", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "armorType", + "columnName": "armor_type", + "affinity": "TEXT", + "defaultValue": "'none'" + }, + { + "fieldPath": "shieldBonus", + "columnName": "shield_bonus", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "naturalArmorBonus", + "columnName": "natural_armor_bonus", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "otherArmorDescription", + "columnName": "other_armor_description", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "hitDice", + "columnName": "hit_dice", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "hasCustomHP", + "columnName": "has_custom_hit_points", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "customHPDescription", + "columnName": "custom_hit_points_description", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "walkSpeed", + "columnName": "walk_speed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "burrowSpeed", + "columnName": "burrow_speed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "climbSpeed", + "columnName": "climb_speed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "flySpeed", + "columnName": "fly_speed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canHover", + "columnName": "can_hover", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "swimSpeed", + "columnName": "swim_speed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasCustomSpeed", + "columnName": "has_custom_speed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "customSpeedDescription", + "columnName": "custom_speed_description", + "affinity": "TEXT" + }, + { + "fieldPath": "challengeRating", + "columnName": "challenge_rating", + "affinity": "TEXT", + "defaultValue": "'1'" + }, + { + "fieldPath": "customChallengeRatingDescription", + "columnName": "custom_challenge_rating_description", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "customProficiencyBonus", + "columnName": "custom_proficiency_bonus", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "telepathyRange", + "columnName": "telepathy_range", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "understandsButDescription", + "columnName": "understands_but_description", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "senses", + "columnName": "senses", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "skills", + "columnName": "skills", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "damageImmunities", + "columnName": "damage_immunities", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "damageResistances", + "columnName": "damage_resistances", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "damageVulnerabilities", + "columnName": "damage_vulnerabilities", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "conditionImmunities", + "columnName": "condition_immunities", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "languages", + "columnName": "languages", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "abilities", + "columnName": "abilities", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "actions", + "columnName": "actions", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "reactions", + "columnName": "reactions", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "lairActions", + "columnName": "lair_actions", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "legendaryActions", + "columnName": "legendary_actions", + "affinity": "TEXT", + "defaultValue": "'[]'" + }, + { + "fieldPath": "regionalActions", + "columnName": "regional_actions", + "affinity": "TEXT", + "defaultValue": "'[]'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "monsters_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`name` TEXT, `size` TEXT, `type` TEXT, `subtype` TEXT, `alignment` TEXT, content=`monsters`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "TEXT" + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT" + }, + { + "fieldPath": "subtype", + "columnName": "subtype", + "affinity": "TEXT" + }, + { + "fieldPath": "alignment", + "columnName": "alignment", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "monsters", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_monsters_fts_BEFORE_UPDATE BEFORE UPDATE ON `monsters` BEGIN DELETE FROM `monsters_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_monsters_fts_BEFORE_DELETE BEFORE DELETE ON `monsters` BEGIN DELETE FROM `monsters_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_monsters_fts_AFTER_UPDATE AFTER UPDATE ON `monsters` BEGIN INSERT INTO `monsters_fts`(`docid`, `name`, `size`, `type`, `subtype`, `alignment`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`size`, NEW.`type`, NEW.`subtype`, NEW.`alignment`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_monsters_fts_AFTER_INSERT AFTER INSERT ON `monsters` BEGIN INSERT INTO `monsters_fts`(`docid`, `name`, `size`, `type`, `subtype`, `alignment`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`size`, NEW.`type`, NEW.`subtype`, NEW.`alignment`); END" + ] + }, + { + "tableName": "collections", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `description` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "collection_monsters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collection_id` TEXT NOT NULL, `monster_id` TEXT NOT NULL, `ordinal` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`collection_id`) REFERENCES `collections`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`monster_id`) REFERENCES `monsters`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "collectionId", + "columnName": "collection_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "monsterId", + "columnName": "monster_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ordinal", + "columnName": "ordinal", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_collection_monsters_collection_id", + "unique": false, + "columnNames": [ + "collection_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_monsters_collection_id` ON `${TABLE_NAME}` (`collection_id`)" + }, + { + "name": "index_collection_monsters_monster_id", + "unique": false, + "columnNames": [ + "monster_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_collection_monsters_monster_id` ON `${TABLE_NAME}` (`monster_id`)" + } + ], + "foreignKeys": [ + { + "table": "collections", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "collection_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "monsters", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "monster_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ca0ad042eed557daa5ff0ada5de6596c')" + ] + } +} \ No newline at end of file diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/AppDatabase.java b/Android/app/src/main/java/com/majinnaibu/monstercards/AppDatabase.java index 7b27aa6..3bfdfb7 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/AppDatabase.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/AppDatabase.java @@ -4,6 +4,7 @@ import androidx.room.Database; import androidx.room.RoomDatabase; import androidx.room.TypeConverters; +import com.majinnaibu.monstercards.data.CollectionDAO; import com.majinnaibu.monstercards.data.MonsterDAO; import com.majinnaibu.monstercards.data.converters.ArmorTypeConverter; import com.majinnaibu.monstercards.data.converters.ChallengeRatingConverter; @@ -12,10 +13,12 @@ import com.majinnaibu.monstercards.data.converters.SetOfLanguageConverter; import com.majinnaibu.monstercards.data.converters.SetOfSkillConverter; import com.majinnaibu.monstercards.data.converters.SetOfStringConverter; import com.majinnaibu.monstercards.data.converters.UUIDConverter; +import com.majinnaibu.monstercards.models.Collection; +import com.majinnaibu.monstercards.models.CollectionMonster; import com.majinnaibu.monstercards.models.Monster; import com.majinnaibu.monstercards.models.MonsterFTS; -@Database(entities = {Monster.class, MonsterFTS.class}, version = 3) +@Database(entities = {Monster.class, MonsterFTS.class, Collection.class, CollectionMonster.class}, version = 4) @TypeConverters({ ArmorTypeConverter.class, ChallengeRatingConverter.class, @@ -27,4 +30,5 @@ import com.majinnaibu.monstercards.models.MonsterFTS; }) public abstract class AppDatabase extends RoomDatabase { public abstract MonsterDAO monsterDAO(); + public abstract CollectionDAO collectionDAO(); } diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/MonsterCardsApplication.java b/Android/app/src/main/java/com/majinnaibu/monstercards/MonsterCardsApplication.java index fbc21c8..67d94ad 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/MonsterCardsApplication.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/MonsterCardsApplication.java @@ -35,6 +35,15 @@ public class MonsterCardsApplication extends Application { database.execSQL("ALTER TABLE new_monsters RENAME TO monsters"); } }; + private static final Migration MIGRATION_3_4 = new Migration(3, 4) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE IF NOT EXISTS `collections` (`id` TEXT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `description` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))"); + database.execSQL("CREATE TABLE IF NOT EXISTS `collection_monsters` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `collection_id` TEXT NOT NULL, `monster_id` TEXT NOT NULL, `ordinal` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`collection_id`) REFERENCES `collections`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE, FOREIGN KEY(`monster_id`) REFERENCES `monsters`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)"); + database.execSQL("CREATE INDEX IF NOT EXISTS `index_collection_monsters_collection_id` ON `collection_monsters` (`collection_id`)"); + database.execSQL("CREATE INDEX IF NOT EXISTS `index_collection_monsters_monster_id` ON `collection_monsters` (`monster_id`)"); + } + }; private MonsterRepository m_monsterLibraryRepository; @@ -56,6 +65,7 @@ public class MonsterCardsApplication extends Application { AppDatabase m_db = Room.databaseBuilder(getApplicationContext(), AppDatabase.class, "monsters") .addMigrations(MIGRATION_1_2) .addMigrations(MIGRATION_2_3) + .addMigrations(MIGRATION_3_4) .fallbackToDestructiveMigrationOnDowngrade() // .fallbackToDestructiveMigration() .build(); diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/data/CollectionDAO.java b/Android/app/src/main/java/com/majinnaibu/monstercards/data/CollectionDAO.java new file mode 100644 index 0000000..c687649 --- /dev/null +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/data/CollectionDAO.java @@ -0,0 +1,55 @@ +package com.majinnaibu.monstercards.data; + +import androidx.room.Dao; +import androidx.room.Delete; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Update; + +import com.majinnaibu.monstercards.models.Collection; +import com.majinnaibu.monstercards.models.CollectionMonster; +import com.majinnaibu.monstercards.models.CollectionWithCount; +import com.majinnaibu.monstercards.models.Monster; + +import java.util.List; + +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Flowable; + +@Dao +public interface CollectionDAO { + + @Query("SELECT * FROM collections") + Flowable> getAll(); + + @Query("SELECT collections.*, COUNT(collection_monsters.id) as monsterCount FROM collections LEFT JOIN collection_monsters ON collections.id = collection_monsters.collection_id GROUP BY collections.id") + Flowable> getCollectionsWithCount(); + + @Query("SELECT * FROM collections WHERE id = :id LIMIT 1") + Flowable getById(String id); + + @Insert(onConflict = OnConflictStrategy.REPLACE) + Completable save(Collection... collections); + + @Delete + Completable delete(Collection collection); + + @Query("SELECT monsters.* FROM monsters INNER JOIN collection_monsters ON monsters.id = collection_monsters.monster_id WHERE collection_monsters.collection_id = :collectionId ORDER BY collection_monsters.ordinal ASC, collection_monsters.id ASC") + Flowable> getMonstersForCollection(String collectionId); + + @Query("SELECT * FROM collection_monsters WHERE collection_id = :collectionId ORDER BY ordinal ASC, id ASC") + Flowable> getCollectionMonstersForCollection(String collectionId); + + @Insert + Completable addMonsterToCollection(CollectionMonster... collectionMonsters); + + @Query("DELETE FROM collection_monsters WHERE collection_id = :collectionId AND monster_id = :monsterId") + Completable removeMonsterFromCollection(String collectionId, String monsterId); + + @Query("DELETE FROM collection_monsters WHERE id = :id") + Completable removeCollectionMonsterById(long id); + + @Update + Completable updateCollectionMonsters(List collectionMonsters); +} diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/data/MonsterRepository.java b/Android/app/src/main/java/com/majinnaibu/monstercards/data/MonsterRepository.java index 842fb2d..5aba6b2 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/data/MonsterRepository.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/data/MonsterRepository.java @@ -4,6 +4,9 @@ import androidx.annotation.NonNull; import com.majinnaibu.monstercards.AppDatabase; import com.majinnaibu.monstercards.helpers.StringHelper; +import com.majinnaibu.monstercards.models.Collection; +import com.majinnaibu.monstercards.models.CollectionMonster; +import com.majinnaibu.monstercards.models.CollectionWithCount; import com.majinnaibu.monstercards.models.Monster; import java.util.ArrayList; @@ -80,6 +83,82 @@ public class MonsterRepository { return result; } + public Flowable> getCollections() { + return m_db.collectionDAO() + .getAll() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } + + public Flowable> getCollectionsWithCount() { + return m_db.collectionDAO() + .getCollectionsWithCount() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } + + public Flowable getCollection(@NonNull UUID collectionId) { + return m_db.collectionDAO() + .getById(collectionId.toString()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } + + public Completable saveCollection(Collection collection) { + Completable result = m_db.collectionDAO().save(collection); + result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()); + return result; + } + + public Completable deleteCollection(Collection collection) { + Completable result = m_db.collectionDAO().delete(collection); + result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()); + return result; + } + + public Flowable> getMonstersForCollection(@NonNull UUID collectionId) { + return m_db.collectionDAO() + .getMonstersForCollection(collectionId.toString()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } + + public Flowable> getCollectionMonstersForCollection(@NonNull UUID collectionId) { + return m_db.collectionDAO() + .getCollectionMonstersForCollection(collectionId.toString()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } + + public Completable addMonsterToCollection(@NonNull UUID collectionId, @NonNull UUID monsterId) { + return addMonsterToCollection(collectionId, monsterId, 0); + } + + public Completable addMonsterToCollection(@NonNull UUID collectionId, @NonNull UUID monsterId, int ordinal) { + CollectionMonster collectionMonster = new CollectionMonster(collectionId, monsterId, ordinal); + Completable result = m_db.collectionDAO().addMonsterToCollection(collectionMonster); + result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()); + return result; + } + + public Completable removeMonsterFromCollection(@NonNull UUID collectionId, @NonNull UUID monsterId) { + Completable result = m_db.collectionDAO().removeMonsterFromCollection(collectionId.toString(), monsterId.toString()); + result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()); + return result; + } + + public Completable removeCollectionMonsterById(long junctionId) { + Completable result = m_db.collectionDAO().removeCollectionMonsterById(junctionId); + result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()); + return result; + } + + public Completable updateCollectionMonsters(List collectionMonsters) { + Completable result = m_db.collectionDAO().updateCollectionMonsters(collectionMonsters); + result.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()); + return result; + } + private static class Helpers { static boolean monsterMatchesSearch(Monster monster, String searchText) { if (StringHelper.isNullOrEmpty(searchText)) { diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/models/Collection.java b/Android/app/src/main/java/com/majinnaibu/monstercards/models/Collection.java new file mode 100644 index 0000000..833ff3c --- /dev/null +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/models/Collection.java @@ -0,0 +1,53 @@ +package com.majinnaibu.monstercards.models; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.PrimaryKey; + +import java.util.Objects; +import java.util.UUID; + +@Entity(tableName = "collections") +public class Collection { + + @PrimaryKey + @NonNull + public UUID id; + + @NonNull + @ColumnInfo(defaultValue = "") + public String name; + + @NonNull + @ColumnInfo(defaultValue = "") + public String description; + + public Collection() { + this.id = UUID.randomUUID(); + this.name = ""; + this.description = ""; + } + + public Collection(@NonNull String name, @NonNull String description) { + this.id = UUID.randomUUID(); + this.name = name; + this.description = description; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + Collection that = (Collection) obj; + return Objects.equals(id, that.id) && + Objects.equals(name, that.name) && + Objects.equals(description, that.description); + } + + @Override + public int hashCode() { + return Objects.hash(id, name, description); + } +} diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/models/CollectionMonster.java b/Android/app/src/main/java/com/majinnaibu/monstercards/models/CollectionMonster.java new file mode 100644 index 0000000..1b9c409 --- /dev/null +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/models/CollectionMonster.java @@ -0,0 +1,60 @@ +package com.majinnaibu.monstercards.models; + +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.ForeignKey; +import androidx.room.Index; +import androidx.room.PrimaryKey; + +import java.util.UUID; + +@Entity( + tableName = "collection_monsters", + foreignKeys = { + @ForeignKey( + entity = Collection.class, + parentColumns = "id", + childColumns = "collection_id", + onDelete = ForeignKey.CASCADE + ), + @ForeignKey( + entity = Monster.class, + parentColumns = "id", + childColumns = "monster_id", + onDelete = ForeignKey.CASCADE + ) + }, + indices = { + @Index(value = {"collection_id"}), + @Index(value = {"monster_id"}) + } +) +public class CollectionMonster { + + @PrimaryKey(autoGenerate = true) + public long id; + + @NonNull + @ColumnInfo(name = "collection_id") + public UUID collectionId; + + @NonNull + @ColumnInfo(name = "monster_id") + public UUID monsterId; + + @ColumnInfo(name = "ordinal", defaultValue = "0") + public int ordinal; + + public CollectionMonster() { + this.collectionId = UUID.fromString("00000000-0000-0000-0000-000000000000"); + this.monsterId = UUID.fromString("00000000-0000-0000-0000-000000000000"); + this.ordinal = 0; + } + + public CollectionMonster(@NonNull UUID collectionId, @NonNull UUID monsterId, int ordinal) { + this.collectionId = collectionId; + this.monsterId = monsterId; + this.ordinal = ordinal; + } +} diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/models/CollectionWithCount.java b/Android/app/src/main/java/com/majinnaibu/monstercards/models/CollectionWithCount.java new file mode 100644 index 0000000..f5a0850 --- /dev/null +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/models/CollectionWithCount.java @@ -0,0 +1,20 @@ +package com.majinnaibu.monstercards.models; + +import androidx.room.Embedded; + +public class CollectionWithCount { + @Embedded + public Collection collection; + + public int monsterCount; + + public CollectionWithCount() { + this.collection = new Collection(); + this.monsterCount = 0; + } + + public CollectionWithCount(Collection collection, int monsterCount) { + this.collection = collection; + this.monsterCount = monsterCount; + } +} diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionDetailFragment.java b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionDetailFragment.java new file mode 100644 index 0000000..0ac3180 --- /dev/null +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionDetailFragment.java @@ -0,0 +1,180 @@ +package com.majinnaibu.monstercards.ui.collections; + +import android.content.Context; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.NavDirections; +import androidx.navigation.Navigation; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.snackbar.Snackbar; +import com.majinnaibu.monstercards.R; +import com.majinnaibu.monstercards.data.MonsterRepository; +import com.majinnaibu.monstercards.models.Collection; +import com.majinnaibu.monstercards.models.Monster; +import com.majinnaibu.monstercards.ui.dashboard.DashboardRecyclerViewAdapter; +import com.majinnaibu.monstercards.ui.shared.MCFragment; +import com.majinnaibu.monstercards.utils.Logger; + +import java.util.List; +import java.util.UUID; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public class CollectionDetailFragment extends MCFragment { + + private CollectionDetailViewModel mViewModel; + private UUID mCollectionId; + private DashboardRecyclerViewAdapter mAdapter; + private final CompositeDisposable mDisposables = new CompositeDisposable(); + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + View root = inflater.inflate(R.layout.fragment_collection_detail, container, false); + + Bundle arguments = getArguments(); + assert arguments != null; + mCollectionId = UUID.fromString(CollectionDetailFragmentArgs.fromBundle(arguments).getCollectionId()); + + mViewModel = new ViewModelProvider(this).get(CollectionDetailViewModel.class); + + TextView nameView = root.findViewById(R.id.detail_collection_name); + TextView descriptionView = root.findViewById(R.id.detail_collection_description); + RecyclerView recyclerView = root.findViewById(R.id.collection_monster_list); + FloatingActionButton fab = root.findViewById(R.id.fab_add_monster_to_collection); + + if (fab != null) { + fab.setOnClickListener(v -> showAddMonsterDialog()); + } + + mViewModel.getCollection().observe(getViewLifecycleOwner(), collection -> { + if (collection != null) { + nameView.setText(collection.name); + setTitle(collection.name); + if (!TextUtils.isEmpty(collection.description)) { + descriptionView.setVisibility(View.VISIBLE); + descriptionView.setText(collection.description); + } else { + descriptionView.setVisibility(View.GONE); + } + } + }); + + mViewModel.getMonsters().observe(getViewLifecycleOwner(), monsters -> { + if (mAdapter != null) { + mAdapter.submitList(monsters); + } + }); + + setupRecyclerView(recyclerView); + loadCollectionData(); + + return root; + } + + private void loadCollectionData() { + MonsterRepository repository = getMonsterRepository(); + mDisposables.add(repository.getCollection(mCollectionId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(collection -> { + if (collection != null) { + mViewModel.setCollection(collection); + } + }, Logger::logError)); + + mDisposables.add(repository.getMonstersForCollection(mCollectionId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(monsters -> mViewModel.setMonsters(monsters), Logger::logError)); + } + + private void setupRecyclerView(@NonNull RecyclerView recyclerView) { + int columnCount = Math.max(1, getResources().getConfiguration().screenWidthDp / 396); + Context context = requireContext(); + GridLayoutManager layoutManager = new GridLayoutManager(context, columnCount); + recyclerView.setLayoutManager(layoutManager); + + mAdapter = new DashboardRecyclerViewAdapter(monster -> { + if (monster != null) { + navigateToMonsterDetail(monster.id); + } else { + Logger.logError("Can't navigate to MonsterDetailFragment with a null monster"); + } + }); + recyclerView.setAdapter(mAdapter); + } + + private void showAddMonsterDialog() { + MonsterRepository repository = getMonsterRepository(); + mDisposables.add(repository.getMonsters() + .firstOrError() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(monsters -> { + if (monsters.isEmpty()) { + View view = getView(); + if (view != null) { + Snackbar.make(view, getString(R.string.snackbar_failed_to_create_monster), Snackbar.LENGTH_LONG).show(); + } + return; + } + String[] monsterNames = new String[monsters.size()]; + for (int i = 0; i < monsters.size(); i++) { + monsterNames[i] = monsters.get(i).name; + } + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.action_add_monster) + .setItems(monsterNames, (dialog, which) -> { + Monster selectedMonster = monsters.get(which); + addMonsterToCollection(selectedMonster); + }) + .setNegativeButton(R.string.dialog_cancel, null) + .show(); + }, Logger::logError)); + } + + private void addMonsterToCollection(Monster monster) { + MonsterRepository repository = getMonsterRepository(); + mDisposables.add(repository.addMonsterToCollection(mCollectionId, monster.id) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + View view = getView(); + if (view != null) { + Collection collection = mViewModel.getCollection().getValue(); + String collectionName = collection != null ? collection.name : ""; + Snackbar.make( + view, + getString(R.string.snackbar_monster_added_to_collection, monster.name, collectionName), + Snackbar.LENGTH_LONG) + .show(); + } + }, Logger::logError)); + } + + private void navigateToMonsterDetail(@NonNull UUID monsterId) { + NavDirections action = CollectionDetailFragmentDirections.actionCollectionDetailFragmentToNavigationMonster(monsterId.toString()); + Navigation.findNavController(requireView()).navigate(action); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + mDisposables.clear(); + } +} diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionDetailViewModel.java b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionDetailViewModel.java new file mode 100644 index 0000000..a45cf19 --- /dev/null +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionDetailViewModel.java @@ -0,0 +1,36 @@ +package com.majinnaibu.monstercards.ui.collections; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import com.majinnaibu.monstercards.models.Collection; +import com.majinnaibu.monstercards.models.Monster; + +import java.util.ArrayList; +import java.util.List; + +public class CollectionDetailViewModel extends ViewModel { + private final MutableLiveData mCollection = new MutableLiveData<>(); + private final MutableLiveData> mMonsters = new MutableLiveData<>(new ArrayList<>()); + + public CollectionDetailViewModel() { + } + + public LiveData getCollection() { + return mCollection; + } + + public void setCollection(Collection collection) { + mCollection.setValue(collection); + } + + public LiveData> getMonsters() { + return mMonsters; + } + + public void setMonsters(List monsters) { + mMonsters.setValue(monsters); + } +} diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionsFragment.java b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionsFragment.java index a0fd733..8f52c17 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionsFragment.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionsFragment.java @@ -1,27 +1,158 @@ package com.majinnaibu.monstercards.ui.collections; +import android.content.Context; import android.os.Bundle; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.TextView; +import android.widget.EditText; +import android.widget.LinearLayout; import androidx.annotation.NonNull; -import androidx.lifecycle.ViewModelProvider; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.navigation.NavDirections; +import androidx.navigation.Navigation; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.snackbar.Snackbar; import com.majinnaibu.monstercards.R; +import com.majinnaibu.monstercards.data.MonsterRepository; +import com.majinnaibu.monstercards.models.Collection; import com.majinnaibu.monstercards.ui.shared.MCFragment; +import com.majinnaibu.monstercards.ui.shared.SwipeToDeleteCallback; +import com.majinnaibu.monstercards.utils.Logger; + +import java.util.UUID; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.observers.DisposableCompletableObserver; +import io.reactivex.rxjava3.schedulers.Schedulers; public class CollectionsFragment extends MCFragment { - private CollectionsViewModel collectionsViewModel; - + @Nullable + @Override public View onCreateView(@NonNull LayoutInflater inflater, - ViewGroup container, Bundle savedInstanceState) { - collectionsViewModel = new ViewModelProvider(this).get(CollectionsViewModel.class); + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { View root = inflater.inflate(R.layout.fragment_collections, container, false); - final TextView textView = root.findViewById(R.id.text_collections); - collectionsViewModel.getText().observe(getViewLifecycleOwner(), textView::setText); + + FloatingActionButton fab = root.findViewById(R.id.fab_add_collection); + if (fab != null) { + fab.setOnClickListener(v -> showCreateCollectionDialog()); + } + + RecyclerView recyclerView = root.findViewById(R.id.collection_list); + if (recyclerView != null) { + setupRecyclerView(recyclerView); + } + return root; } + + private void setupRecyclerView(@NonNull RecyclerView recyclerView) { + Context context = requireContext(); + MonsterRepository repository = getMonsterRepository(); + + CollectionsRecyclerViewAdapter adapter = new CollectionsRecyclerViewAdapter( + context, + repository.getCollectionsWithCount(), + collection -> navigateToCollectionDetail(collection.id), + collection -> repository.deleteCollection(collection) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new DisposableCompletableObserver() { + @Override + public void onComplete() { + } + + @Override + public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) { + Logger.logError(e); + } + }) + ); + + recyclerView.setAdapter(adapter); + LinearLayoutManager layoutManager = new LinearLayoutManager(context); + recyclerView.setLayoutManager(layoutManager); + + DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(context, layoutManager.getOrientation()); + recyclerView.addItemDecoration(dividerItemDecoration); + + ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new SwipeToDeleteCallback(context, (position, direction) -> adapter.deleteItem(position), null)); + itemTouchHelper.attachToRecyclerView(recyclerView); + } + + private void showCreateCollectionDialog() { + Context context = requireContext(); + LinearLayout layout = new LinearLayout(context); + layout.setOrientation(LinearLayout.VERTICAL); + int padding = (int) (16 * getResources().getDisplayMetrics().density); + layout.setPadding(padding, padding, padding, padding); + + final EditText nameInput = new EditText(context); + nameInput.setHint(R.string.label_collection_name); + layout.addView(nameInput); + + final EditText descInput = new EditText(context); + descInput.setHint(R.string.label_collection_description); + layout.addView(descInput); + + new AlertDialog.Builder(context) + .setTitle(R.string.title_new_collection) + .setView(layout) + .setPositiveButton(R.string.dialog_create, (dialog, which) -> { + String name = nameInput.getText().toString().trim(); + String description = descInput.getText().toString().trim(); + if (TextUtils.isEmpty(name)) { + name = getString(R.string.title_new_collection); + } + createCollection(name, description); + }) + .setNegativeButton(R.string.dialog_cancel, null) + .show(); + } + + private void createCollection(@NonNull String name, @NonNull String description) { + Collection collection = new Collection(name, description); + MonsterRepository repository = getMonsterRepository(); + repository.saveCollection(collection) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new DisposableCompletableObserver() { + @Override + public void onComplete() { + View view = getView(); + if (view != null) { + Snackbar.make( + view, + getString(R.string.snackbar_collection_created, collection.name), + Snackbar.LENGTH_LONG) + .setAction("View", v -> navigateToCollectionDetail(collection.id)) + .show(); + } + } + + @Override + public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) { + Logger.logError("Error creating collection", e); + View view = getView(); + if (view != null) { + Snackbar.make(view, getString(R.string.snackbar_failed_to_create_collection), Snackbar.LENGTH_LONG).show(); + } + } + }); + } + + private void navigateToCollectionDetail(@NonNull UUID collectionId) { + NavDirections action = CollectionsFragmentDirections.actionNavigationCollectionsToCollectionDetailFragment(collectionId.toString()); + Navigation.findNavController(requireView()).navigate(action); + } } diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionsRecyclerViewAdapter.java b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionsRecyclerViewAdapter.java new file mode 100644 index 0000000..f0535fc --- /dev/null +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/collections/CollectionsRecyclerViewAdapter.java @@ -0,0 +1,137 @@ +package com.majinnaibu.monstercards.ui.collections; + +import android.content.Context; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.majinnaibu.monstercards.R; +import com.majinnaibu.monstercards.models.Collection; +import com.majinnaibu.monstercards.models.CollectionWithCount; + +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public class CollectionsRecyclerViewAdapter extends RecyclerView.Adapter { + private final Context mContext; + private final CollectionCallback mOnClick; + private final CollectionCallback mOnDelete; + private final Flowable> mItemsObservable; + private List mValues; + private Disposable mDisposable; + + private final View.OnClickListener mOnClickListener = new View.OnClickListener() { + @Override + public void onClick(@NonNull View view) { + Collection collection = (Collection) view.getTag(); + if (mOnClick != null) { + mOnClick.onCallback(collection); + } + } + }; + + public CollectionsRecyclerViewAdapter(Context context, + Flowable> itemsObservable, + CollectionCallback onClick, + CollectionCallback onDelete) { + mContext = context; + mItemsObservable = itemsObservable; + mOnClick = onClick; + mOnDelete = onDelete; + mValues = new ArrayList<>(); + mDisposable = null; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.collection_list_item, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + CollectionWithCount item = mValues.get(position); + Collection collection = item.collection; + holder.mNameView.setText(collection.name); + + String countText; + if (item.monsterCount == 1) { + countText = mContext.getString(R.string.format_monster_count_one); + } else { + countText = mContext.getString(R.string.format_monster_count_other, item.monsterCount); + } + holder.mCountView.setText(countText); + holder.mCountView.setVisibility(View.VISIBLE); + + if (!TextUtils.isEmpty(collection.description)) { + holder.mDescriptionView.setVisibility(View.VISIBLE); + holder.mDescriptionView.setText(collection.description); + } else { + holder.mDescriptionView.setVisibility(View.GONE); + } + + holder.itemView.setTag(collection); + holder.itemView.setOnClickListener(mOnClickListener); + } + + @Override + public int getItemCount() { + return mValues.size(); + } + + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + super.onAttachedToRecyclerView(recyclerView); + mDisposable = mItemsObservable + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(collections -> { + mValues = collections; + notifyDataSetChanged(); + }); + } + + @Override + public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { + super.onDetachedFromRecyclerView(recyclerView); + if (mDisposable != null) { + mDisposable.dispose(); + } + } + + public void deleteItem(int position) { + if (mOnDelete != null && position >= 0 && position < mValues.size()) { + Collection collection = mValues.get(position).collection; + mOnDelete.onCallback(collection); + } + } + + public interface CollectionCallback { + void onCallback(Collection collection); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + final TextView mNameView; + final TextView mDescriptionView; + final TextView mCountView; + + ViewHolder(View view) { + super(view); + mNameView = view.findViewById(R.id.collection_name); + mDescriptionView = view.findViewById(R.id.collection_description); + mCountView = view.findViewById(R.id.collection_count); + } + } +} diff --git a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/dashboard/DashboardRecyclerViewAdapter.java b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/dashboard/DashboardRecyclerViewAdapter.java index d6e6149..af372bd 100644 --- a/Android/app/src/main/java/com/majinnaibu/monstercards/ui/dashboard/DashboardRecyclerViewAdapter.java +++ b/Android/app/src/main/java/com/majinnaibu/monstercards/ui/dashboard/DashboardRecyclerViewAdapter.java @@ -38,7 +38,7 @@ public class DashboardRecyclerViewAdapter extends ListAdapter>() { + @Override + public void onSuccess(@io.reactivex.rxjava3.annotations.NonNull List collections) { + if (collections.isEmpty()) { + View view = getView(); + if (view != null) { + Snackbar.make(view, getString(R.string.no_collections_available), Snackbar.LENGTH_LONG).show(); + } + dispose(); + return; + } + String[] collectionNames = new String[collections.size()]; + for (int i = 0; i < collections.size(); i++) { + collectionNames[i] = collections.get(i).name; + } + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.title_select_collection) + .setItems(collectionNames, (dialog, which) -> { + Collection selectedCollection = collections.get(which); + addMonsterToCollection(selectedCollection, monsterId); + }) + .setNegativeButton(R.string.dialog_cancel, null) + .show(); + dispose(); + } + + @Override + public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) { + Logger.logError(e); + dispose(); + } + }); + } + + private void addMonsterToCollection(@NonNull Collection collection, @NonNull UUID monsterId) { + MonsterRepository repository = getMonsterRepository(); + repository.addMonsterToCollection(collection.id, monsterId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(new DisposableCompletableObserver() { + @Override + public void onComplete() { + View view = getView(); + if (view != null) { + String monsterName = mViewModel.getName().getValue(); + Snackbar.make( + view, + getString(R.string.snackbar_monster_added_to_collection, monsterName, collection.name), + Snackbar.LENGTH_LONG) + .show(); + } + } + + @Override + public void onError(@io.reactivex.rxjava3.annotations.NonNull Throwable e) { + Logger.logError(e); + } + }); + } + private static class ViewHolder { final TextView name; final TextView meta; diff --git a/Android/app/src/main/res/layout/collection_list_item.xml b/Android/app/src/main/res/layout/collection_list_item.xml new file mode 100644 index 0000000..53f6134 --- /dev/null +++ b/Android/app/src/main/res/layout/collection_list_item.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + diff --git a/Android/app/src/main/res/layout/fragment_collection_detail.xml b/Android/app/src/main/res/layout/fragment_collection_detail.xml new file mode 100644 index 0000000..33e1cdc --- /dev/null +++ b/Android/app/src/main/res/layout/fragment_collection_detail.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + diff --git a/Android/app/src/main/res/layout/fragment_collections.xml b/Android/app/src/main/res/layout/fragment_collections.xml index 5ff5356..60c9863 100644 --- a/Android/app/src/main/res/layout/fragment_collections.xml +++ b/Android/app/src/main/res/layout/fragment_collections.xml @@ -6,17 +6,26 @@ android:layout_height="match_parent" tools:context=".ui.collections.CollectionsFragment"> - - \ No newline at end of file + app:layout_constraintTop_toTopOf="parent" + tools:listitem="@layout/collection_list_item" /> + + + diff --git a/Android/app/src/main/res/menu/monster_detail_menu.xml b/Android/app/src/main/res/menu/monster_detail_menu.xml index 91a8230..b11b542 100644 --- a/Android/app/src/main/res/menu/monster_detail_menu.xml +++ b/Android/app/src/main/res/menu/monster_detail_menu.xml @@ -2,6 +2,12 @@ + + + + + + + Add Ability + Add collection + Add to Collection Add Condition Add Damage Type Add Item @@ -137,4 +139,17 @@ %1$s Details Search WIS + New Collection + Collection Details + Select Collection + Collection Name + Description (optional) + Create + Cancel + Collection %1$s created + Added %1$s to %2$s + Failed to create collection + No collections available. Create one first! + 1 monster + %d monsters