diff --git a/app/build.gradle b/app/build.gradle index 6da60161..71bad914 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,7 +9,7 @@ apply plugin: 'org.sonarqube' static def getKey(String env, String key) { Properties props = new Properties() props.load(new FileInputStream(new File(env.concat('.properties')))) - return props[key] + return props[key] ?: "" } androidExtensions { @@ -24,8 +24,8 @@ android { applicationId "ro.code4.monitorizarevot" minSdkVersion 21 targetSdkVersion 29 - versionCode 35 - versionName "2.0.7" + versionCode 42 + versionName "2.1.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" javaCompileOptions { @@ -57,6 +57,7 @@ android { variant.buildConfigField "String", "API_URL", "\"" + getKey(variant.buildType.name, "apiUrl") + "\"" variant.buildConfigField "String", "GUIDE_URL", "\"" + getKey(variant.buildType.name, "guideUrl") + "\"" variant.buildConfigField "String", "SAFETY_URL", "\"" + getKey(variant.buildType.name, "safetyUrl") + "\"" + variant.buildConfigField "String", "OBSERVER_FEEDBACK_URL", "\"" + getKey(variant.buildType.name, "observerFeedbackUrl") + "\"" variant.buildConfigField "String", "ORGANISATION_WEB_URL", "\"" + getKey(variant.buildType.name, "organisationWebUrl") + "\"" variant.buildConfigField "String", "SERVICE_CENTER_PHONE_NUMBER", "\"" + getKey(variant.buildType.name, "serviceCenterPhoneNumber") + "\"" variant.buildConfigField "String", "PREFERRED_LOCALE", "\"" + getKey(variant.buildType.name, "preferredLocale") + "\"" @@ -154,6 +155,7 @@ dependencies { kapt "androidx.room:room-compiler:$roomVersion" implementation "androidx.room:room-ktx:$roomVersion" implementation "androidx.room:room-rxjava2:$roomVersion" + implementation 'com.squareup.picasso:picasso:2.71828' // Unit tests testImplementation 'junit:junit:4.13' diff --git a/app/schemas/ro.code4.monitorizarevot.data.AppDatabase/4.json b/app/schemas/ro.code4.monitorizarevot.data.AppDatabase/4.json new file mode 100644 index 00000000..10da93ee --- /dev/null +++ b/app/schemas/ro.code4.monitorizarevot.data.AppDatabase/4.json @@ -0,0 +1,661 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "731f85b645fe3e009579832fa33bfb53", + "entities": [ + { + "tableName": "county", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `code` TEXT NOT NULL, `name` TEXT NOT NULL, `limit` INTEGER NOT NULL, `diaspora` INTEGER, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "limit", + "columnName": "limit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "diaspora", + "columnName": "diaspora", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_county_code", + "unique": true, + "columnNames": [ + "code" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_county_code` ON `${TABLE_NAME}` (`code`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "polling_station", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `countyCode` TEXT NOT NULL, `idPollingStation` INTEGER NOT NULL, `urbanArea` INTEGER NOT NULL, `isPollingStationPresidentFemale` INTEGER NOT NULL, `observerArrivalTime` TEXT, `observerLeaveTime` TEXT, `synced` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`countyCode`) REFERENCES `county`(`code`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "countyCode", + "columnName": "countyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idPollingStation", + "columnName": "idPollingStation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "urbanArea", + "columnName": "urbanArea", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPollingStationPresidentFemale", + "columnName": "isPollingStationPresidentFemale", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "observerArrivalTime", + "columnName": "observerArrivalTime", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "observerLeaveTime", + "columnName": "observerLeaveTime", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_polling_station_countyCode_idPollingStation", + "unique": true, + "columnNames": [ + "countyCode", + "idPollingStation" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_polling_station_countyCode_idPollingStation` ON `${TABLE_NAME}` (`countyCode`, `idPollingStation`)" + } + ], + "foreignKeys": [ + { + "table": "county", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "countyCode" + ], + "referencedColumns": [ + "code" + ] + } + ] + }, + { + "tableName": "form_details", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `code` TEXT NOT NULL, `description` TEXT NOT NULL, `formVersion` INTEGER NOT NULL, `diaspora` INTEGER, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "formVersion", + "columnName": "formVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "diaspora", + "columnName": "diaspora", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "section", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uniqueId` TEXT NOT NULL, `code` TEXT, `description` TEXT, `formId` INTEGER NOT NULL, `orderNumber` INTEGER NOT NULL, PRIMARY KEY(`uniqueId`), FOREIGN KEY(`formId`) REFERENCES `form_details`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uniqueId", + "columnName": "uniqueId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "formId", + "columnName": "formId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNumber", + "columnName": "orderNumber", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "uniqueId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "form_details", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "formId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "question", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `text` TEXT NOT NULL, `code` TEXT NOT NULL, `questionType` INTEGER NOT NULL, `sectionId` TEXT NOT NULL, `hasNotes` INTEGER NOT NULL, `orderNumber` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`sectionId`) REFERENCES `section`(`uniqueId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "questionType", + "columnName": "questionType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionId", + "columnName": "sectionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasNotes", + "columnName": "hasNotes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNumber", + "columnName": "orderNumber", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "section", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "sectionId" + ], + "referencedColumns": [ + "uniqueId" + ] + } + ] + }, + { + "tableName": "answer", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idOption` INTEGER NOT NULL, `text` TEXT NOT NULL, `isFreeText` INTEGER NOT NULL, `questionId` INTEGER NOT NULL, `orderNumber` INTEGER NOT NULL, PRIMARY KEY(`idOption`), FOREIGN KEY(`questionId`) REFERENCES `question`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "idOption", + "columnName": "idOption", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFreeText", + "columnName": "isFreeText", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "questionId", + "columnName": "questionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNumber", + "columnName": "orderNumber", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "idOption" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "question", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "questionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "answered_question", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `formId` INTEGER NOT NULL, `questionId` INTEGER NOT NULL, `countyCode` TEXT NOT NULL, `pollingStationNumber` INTEGER NOT NULL, `savedLocally` INTEGER NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`formId`) REFERENCES `form_details`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`questionId`) REFERENCES `question`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`countyCode`, `pollingStationNumber`) REFERENCES `polling_station`(`countyCode`, `idPollingStation`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "formId", + "columnName": "formId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "questionId", + "columnName": "questionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "countyCode", + "columnName": "countyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pollingStationNumber", + "columnName": "pollingStationNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedLocally", + "columnName": "savedLocally", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_answered_question_countyCode_pollingStationNumber_id", + "unique": true, + "columnNames": [ + "countyCode", + "pollingStationNumber", + "id" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_answered_question_countyCode_pollingStationNumber_id` ON `${TABLE_NAME}` (`countyCode`, `pollingStationNumber`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "form_details", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "formId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "question", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "questionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "polling_station", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "countyCode", + "pollingStationNumber" + ], + "referencedColumns": [ + "countyCode", + "idPollingStation" + ] + } + ] + }, + { + "tableName": "selected_answer", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`optionId` INTEGER NOT NULL, `value` TEXT, `countyCode` TEXT NOT NULL, `pollingStationNumber` INTEGER NOT NULL, `questionId` TEXT NOT NULL, PRIMARY KEY(`optionId`, `countyCode`, `pollingStationNumber`), FOREIGN KEY(`optionId`) REFERENCES `answer`(`idOption`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`countyCode`, `pollingStationNumber`, `questionId`) REFERENCES `answered_question`(`countyCode`, `pollingStationNumber`, `id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "optionId", + "columnName": "optionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "countyCode", + "columnName": "countyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pollingStationNumber", + "columnName": "pollingStationNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "questionId", + "columnName": "questionId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "optionId", + "countyCode", + "pollingStationNumber" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "answer", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "optionId" + ], + "referencedColumns": [ + "idOption" + ] + }, + { + "table": "answered_question", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "countyCode", + "pollingStationNumber", + "questionId" + ], + "referencedColumns": [ + "countyCode", + "pollingStationNumber", + "id" + ] + } + ] + }, + { + "tableName": "note", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uriPath` TEXT, `description` TEXT NOT NULL, `questionId` INTEGER, `date` INTEGER NOT NULL, `countyCode` TEXT NOT NULL, `pollingStationNumber` INTEGER NOT NULL, `synced` INTEGER NOT NULL, FOREIGN KEY(`questionId`) REFERENCES `question`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`countyCode`, `pollingStationNumber`) REFERENCES `polling_station`(`countyCode`, `idPollingStation`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uriPath", + "columnName": "uriPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "questionId", + "columnName": "questionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "countyCode", + "columnName": "countyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pollingStationNumber", + "columnName": "pollingStationNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_note_countyCode_pollingStationNumber_questionId", + "unique": false, + "columnNames": [ + "countyCode", + "pollingStationNumber", + "questionId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_countyCode_pollingStationNumber_questionId` ON `${TABLE_NAME}` (`countyCode`, `pollingStationNumber`, `questionId`)" + } + ], + "foreignKeys": [ + { + "table": "question", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "questionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "polling_station", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "countyCode", + "pollingStationNumber" + ], + "referencedColumns": [ + "countyCode", + "idPollingStation" + ] + } + ] + } + ], + "views": [], + "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, '731f85b645fe3e009579832fa33bfb53')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/ro.code4.monitorizarevot.data.AppDatabase/5.json b/app/schemas/ro.code4.monitorizarevot.data.AppDatabase/5.json new file mode 100644 index 00000000..3bfc3cca --- /dev/null +++ b/app/schemas/ro.code4.monitorizarevot.data.AppDatabase/5.json @@ -0,0 +1,673 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "2442667e56ad4886c066a799077327bd", + "entities": [ + { + "tableName": "county", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `code` TEXT NOT NULL, `name` TEXT NOT NULL, `limit` INTEGER NOT NULL, `diaspora` INTEGER, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "limit", + "columnName": "limit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "diaspora", + "columnName": "diaspora", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_county_code", + "unique": true, + "columnNames": [ + "code" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_county_code` ON `${TABLE_NAME}` (`code`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "polling_station", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `countyCode` TEXT NOT NULL, `idPollingStation` INTEGER NOT NULL, `urbanArea` INTEGER NOT NULL, `isPollingStationPresidentFemale` INTEGER NOT NULL, `observerArrivalTime` TEXT, `observerLeaveTime` TEXT, `synced` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`countyCode`) REFERENCES `county`(`code`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "countyCode", + "columnName": "countyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "idPollingStation", + "columnName": "idPollingStation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "urbanArea", + "columnName": "urbanArea", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isPollingStationPresidentFemale", + "columnName": "isPollingStationPresidentFemale", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "observerArrivalTime", + "columnName": "observerArrivalTime", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "observerLeaveTime", + "columnName": "observerLeaveTime", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_polling_station_countyCode_idPollingStation", + "unique": true, + "columnNames": [ + "countyCode", + "idPollingStation" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_polling_station_countyCode_idPollingStation` ON `${TABLE_NAME}` (`countyCode`, `idPollingStation`)" + } + ], + "foreignKeys": [ + { + "table": "county", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "countyCode" + ], + "referencedColumns": [ + "code" + ] + } + ] + }, + { + "tableName": "form_details", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `code` TEXT NOT NULL, `description` TEXT NOT NULL, `formVersion` INTEGER NOT NULL, `diaspora` INTEGER, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "formVersion", + "columnName": "formVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "diaspora", + "columnName": "diaspora", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "section", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uniqueId` TEXT NOT NULL, `code` TEXT, `description` TEXT, `formId` INTEGER NOT NULL, `orderNumber` INTEGER NOT NULL, PRIMARY KEY(`uniqueId`), FOREIGN KEY(`formId`) REFERENCES `form_details`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "uniqueId", + "columnName": "uniqueId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "formId", + "columnName": "formId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNumber", + "columnName": "orderNumber", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "uniqueId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "form_details", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "formId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "question", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `text` TEXT NOT NULL, `code` TEXT NOT NULL, `questionType` INTEGER NOT NULL, `sectionId` TEXT NOT NULL, `hasNotes` INTEGER NOT NULL, `orderNumber` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`sectionId`) REFERENCES `section`(`uniqueId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "questionType", + "columnName": "questionType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionId", + "columnName": "sectionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasNotes", + "columnName": "hasNotes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNumber", + "columnName": "orderNumber", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "section", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "sectionId" + ], + "referencedColumns": [ + "uniqueId" + ] + } + ] + }, + { + "tableName": "answer", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idOption` INTEGER NOT NULL, `text` TEXT NOT NULL, `isFreeText` INTEGER NOT NULL, `questionId` INTEGER NOT NULL, `orderNumber` INTEGER NOT NULL, PRIMARY KEY(`idOption`), FOREIGN KEY(`questionId`) REFERENCES `question`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "idOption", + "columnName": "idOption", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isFreeText", + "columnName": "isFreeText", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "questionId", + "columnName": "questionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orderNumber", + "columnName": "orderNumber", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "idOption" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "question", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "questionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "answered_question", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `formId` INTEGER NOT NULL, `questionId` INTEGER NOT NULL, `countyCode` TEXT NOT NULL, `pollingStationNumber` INTEGER NOT NULL, `savedLocally` INTEGER NOT NULL, `synced` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`formId`) REFERENCES `form_details`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`questionId`) REFERENCES `question`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`countyCode`, `pollingStationNumber`) REFERENCES `polling_station`(`countyCode`, `idPollingStation`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "formId", + "columnName": "formId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "questionId", + "columnName": "questionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "countyCode", + "columnName": "countyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pollingStationNumber", + "columnName": "pollingStationNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedLocally", + "columnName": "savedLocally", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_answered_question_countyCode_pollingStationNumber_id", + "unique": true, + "columnNames": [ + "countyCode", + "pollingStationNumber", + "id" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_answered_question_countyCode_pollingStationNumber_id` ON `${TABLE_NAME}` (`countyCode`, `pollingStationNumber`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "form_details", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "formId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "question", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "questionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "polling_station", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "countyCode", + "pollingStationNumber" + ], + "referencedColumns": [ + "countyCode", + "idPollingStation" + ] + } + ] + }, + { + "tableName": "selected_answer", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`optionId` INTEGER NOT NULL, `value` TEXT, `countyCode` TEXT NOT NULL, `pollingStationNumber` INTEGER NOT NULL, `questionId` TEXT NOT NULL, PRIMARY KEY(`optionId`, `countyCode`, `pollingStationNumber`), FOREIGN KEY(`optionId`) REFERENCES `answer`(`idOption`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`countyCode`, `pollingStationNumber`, `questionId`) REFERENCES `answered_question`(`countyCode`, `pollingStationNumber`, `id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "optionId", + "columnName": "optionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "countyCode", + "columnName": "countyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pollingStationNumber", + "columnName": "pollingStationNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "questionId", + "columnName": "questionId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "optionId", + "countyCode", + "pollingStationNumber" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "answer", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "optionId" + ], + "referencedColumns": [ + "idOption" + ] + }, + { + "table": "answered_question", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "countyCode", + "pollingStationNumber", + "questionId" + ], + "referencedColumns": [ + "countyCode", + "pollingStationNumber", + "id" + ] + } + ] + }, + { + "tableName": "note", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uriPath` TEXT, `description` TEXT NOT NULL, `questionId` INTEGER, `date` INTEGER NOT NULL, `countyCode` TEXT NOT NULL, `pollingStationNumber` INTEGER NOT NULL, `synced` INTEGER NOT NULL, `formCode` TEXT, `questionCode` TEXT, FOREIGN KEY(`questionId`) REFERENCES `question`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`countyCode`, `pollingStationNumber`) REFERENCES `polling_station`(`countyCode`, `idPollingStation`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uriPath", + "columnName": "uriPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "questionId", + "columnName": "questionId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "countyCode", + "columnName": "countyCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pollingStationNumber", + "columnName": "pollingStationNumber", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "formCode", + "columnName": "formCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "questionCode", + "columnName": "questionCode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_note_countyCode_pollingStationNumber_questionId", + "unique": false, + "columnNames": [ + "countyCode", + "pollingStationNumber", + "questionId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_note_countyCode_pollingStationNumber_questionId` ON `${TABLE_NAME}` (`countyCode`, `pollingStationNumber`, `questionId`)" + } + ], + "foreignKeys": [ + { + "table": "question", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "questionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "polling_station", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "countyCode", + "pollingStationNumber" + ], + "referencedColumns": [ + "countyCode", + "idPollingStation" + ] + } + ] + } + ], + "views": [], + "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, '2442667e56ad4886c066a799077327bd')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/ro/code4/monitorizarevot/MigrationTest.kt b/app/src/androidTest/java/ro/code4/monitorizarevot/MigrationTest.kt index 7a697200..e92bbaec 100644 --- a/app/src/androidTest/java/ro/code4/monitorizarevot/MigrationTest.kt +++ b/app/src/androidTest/java/ro/code4/monitorizarevot/MigrationTest.kt @@ -14,6 +14,7 @@ import org.junit.runner.RunWith import ro.code4.monitorizarevot.data.AppDatabase import ro.code4.monitorizarevot.data.Migrations import java.io.IOException +import java.util.* @RunWith(AndroidJUnit4::class) class MigrationTest { @@ -97,6 +98,96 @@ class MigrationTest { } } + @Test + fun migrate3To4() { + check3To4MigrationFor(ContentValues().apply { + put("uniqueId", "unique_section") + put("formId", 100) + }, "section", 5, "SELECT * FROM section") + check3To4MigrationFor(ContentValues().apply { + put("id", 100) + put("text", "question_text") + put("code", "question_code") + put("questionType", 0) + put("sectionId", "section_id") + put("hasNotes", false) + }, "question", 7, "SELECT * FROM question") + check3To4MigrationFor(ContentValues().apply { + put("idOption", 100) + put("text", "answer_text") + put("isFreeText", false) + put("questionId", 0) + }, "answer", 5, "SELECT * FROM answer") + } + + private fun check3To4MigrationFor( + testValues: ContentValues, + tableName: String, + nrOfColumns: Int, + query: String + ) { + helper.createDatabase(TEST_DB, 3).use { + val rowId = it.insert(tableName, SQLiteDatabase.CONFLICT_FAIL, testValues) + assertTrue(rowId > 0) + } + val db = helper.runMigrationsAndValidate(TEST_DB, 4, true, Migrations.MIGRATION_3_4) + val sectionsCursor = db.query(query) + assertNotNull(sectionsCursor) + sectionsCursor.use { + // we have a single row, previously inserted + assertEquals(1, it.count) + assertTrue(it.moveToFirst()) + assertEquals(nrOfColumns, it.columnCount) + // check for the new column "orderNumber" and that it has the default value of 0 + assertEquals(0, it.getInt(it.getColumnIndex("orderNumber"))) + } + } + + @Test + fun migrate4To5() { + val expectedTime = Date().time + helper.createDatabase(TEST_DB, 4).use { + val values = ContentValues().apply { + put("id", 1) + put("uriPath", "/fake/path/on/disk/for/file/image/jpg") + put("description", "description for note") + put("questionId", 12) + put("date", expectedTime) + put("countyCode", "B") + put("pollingStationNumber", 55) + put("synced", false) + } + val rowId = it.insert("note", SQLiteDatabase.CONFLICT_FAIL, values) + assertTrue(rowId > 0) + } + val db = helper.runMigrationsAndValidate(TEST_DB, 5, true, Migrations.MIGRATION_4_5) + val noteDataCursor = db.query("SELECT * FROM note") + assertNotNull(noteDataCursor) + noteDataCursor.use { + // we have a single row, previously inserted + assertEquals(1, it.count) + assertTrue(it.moveToFirst()) + // at this point we expect to have exactly 10 columns for the note table + assertEquals(10, it.columnCount) + // check for the new column "formCode" and that it has the default value of null + assertNull(it.getString(it.getColumnIndex("formCode"))) + // check for the new column "questionCode" and that it has the default value of null + assertNull(it.getString(it.getColumnIndex("questionCode"))) + // check for older columns + assertEquals(1, it.getInt(it.getColumnIndex("id"))) + assertEquals( + "/fake/path/on/disk/for/file/image/jpg", + it.getString(it.getColumnIndex("uriPath")) + ) + assertEquals("description for note", it.getString(it.getColumnIndex("description"))) + assertEquals(12, it.getInt(it.getColumnIndex("questionId"))) + assertEquals(expectedTime, it.getLong(it.getColumnIndex("date"))) + assertEquals(55, it.getInt(it.getColumnIndex("pollingStationNumber"))) + assertEquals("B", it.getString(it.getColumnIndex("countyCode"))) + assertTrue(it.getInt(it.getColumnIndex("synced")) == 0) + } + } + @Test @Throws(IOException::class) fun migrateAll() { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08533953..efffdd33 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,6 +58,14 @@ android:launchMode="singleTop" android:theme="@style/AppTheme.NoActionBar" tools:ignore="LockedOrientationActivity" /> + (DIFF_CALLBACK) { +class NoteDelegationAdapter( + private val noteListener: (Note) -> Unit +) : AsyncListDifferDelegationAdapter(DIFF_CALLBACK) { init { delegatesManager .addDelegate(SectionDelegate()) - .addDelegate(NoteDelegate()) + .addDelegate(NoteDelegate(noteListener)) } companion object { diff --git a/app/src/main/java/ro/code4/monitorizarevot/adapters/NoteDetailsAdapter.kt b/app/src/main/java/ro/code4/monitorizarevot/adapters/NoteDetailsAdapter.kt new file mode 100644 index 00000000..a735295a --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/adapters/NoteDetailsAdapter.kt @@ -0,0 +1,154 @@ +package ro.code4.monitorizarevot.adapters + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.FileProvider +import androidx.recyclerview.widget.RecyclerView +import com.squareup.picasso.Picasso +import ro.code4.monitorizarevot.R +import ro.code4.monitorizarevot.ui.notes.NoteAttachment +import ro.code4.monitorizarevot.ui.notes.NoteDetails +import java.io.File + +class NoteDetailsAdapter( + context: Context +) : RecyclerView.Adapter() { + + private val layoutInflater = LayoutInflater.from(context) + private var details: NoteDetails? = null + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { + TYPE_NOTE_TEXT -> NoteDetailsViewHolderText( + layoutInflater.inflate( + R.layout.item_note_details_text, + parent, + false + ) + ) + TYPE_NOTE_IMAGE -> NoteImageViewHolderDetails( + layoutInflater.inflate( + R.layout.item_note_details_image, + parent, + false + ) + ) + else -> throw IllegalArgumentException("Unknown note row type requested!") + } + + override fun onBindViewHolder(holder: NoteDetailsViewHolder, position: Int) { + holder.bind(details, position) + } + + override fun getItemViewType(position: Int) = when (position) { + 0 -> TYPE_NOTE_TEXT + else -> TYPE_NOTE_IMAGE + } + + override fun getItemCount() = (details?.let { 1 + (details?.attachedFiles?.size ?: 0) } ?: 0) + + fun updateAdapter(noteDetails: NoteDetails) { + this.details = noteDetails + notifyDataSetChanged() + } + + companion object { + const val TYPE_NOTE_TEXT = 1 + const val TYPE_NOTE_IMAGE = 2 + } +} + +sealed class NoteDetailsViewHolder( + rowView: View +) : RecyclerView.ViewHolder(rowView) { + + abstract fun bind(noteDetails: NoteDetails?, position: Int) +} + +class NoteDetailsViewHolderText( + private val rowView: View +) : NoteDetailsViewHolder(rowView) { + private val formQuestionIdentifier: TextView = + rowView.findViewById(R.id.formAndQuestionIdentifier) + private val noteText: TextView = rowView.findViewById(R.id.noteText) + private val noteDate: TextView = rowView.findViewById(R.id.noteDate) + + override fun bind(noteDetails: NoteDetails?, position: Int) { + noteDetails?.let { + formQuestionIdentifier.text = noteDetails.codes?.let { codes -> + rowView.context.getString( + R.string.note_details_codes, codes.formCode, codes.questionCode + ) + } ?: "" + noteText.text = it.description + noteDate.text = it.date + } + } +} + +class NoteImageViewHolderDetails( + private val rowView: View +) : NoteDetailsViewHolder(rowView) { + + private val noteVideoNotice: FrameLayout = rowView.findViewById(R.id.noteVideoNoticeContainer) + private val noteImage: ImageView = rowView.findViewById(R.id.noteImage) + + override fun bind(noteDetails: NoteDetails?, position: Int) { + noteDetails?.let { + // the position is always offset by 1(note text information always occupies the first item in + // the RecyclerView) + val actualPosition = position - 1 + val attachedFile = it.attachedFiles[actualPosition] + val isAFile = attachedFile.uri.scheme?.let { scheme -> scheme != "https" } ?: true + if (attachedFile.isVideo) { + noteImage.visibility = View.GONE + noteVideoNotice.visibility = View.VISIBLE + rowView.setOnClickListener { + setupExternalVideoPreview(rowView.context, attachedFile, isAFile) + } + } else { + rowView.setOnClickListener(null) + noteImage.visibility = View.VISIBLE + noteVideoNotice.visibility = View.GONE + val requestCreator = if (isAFile) { + Picasso.get().load(File(attachedFile.uri.toString())) + } else { + Picasso.get().load(attachedFile.uri) + } + requestCreator.into(noteImage) + } + } + } + + private fun setupExternalVideoPreview( + context: Context, attachedFile: NoteAttachment, isAFile: Boolean + ) = with(context) { + val intent = Intent(Intent.ACTION_VIEW).apply { + type = "video/*" + data = if (isAFile) { + kotlin.runCatching { + FileProvider.getUriForFile( + this@with, packageName, File(attachedFile.uri.toString()) + ) + }.getOrNull() + } else { + attachedFile.uri + } + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + } + if (packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null) { + startActivity(intent) + } else { + Toast.makeText( + this, getString(R.string.note_video_previewer_missing), Toast.LENGTH_SHORT + ).show() + } + } +} diff --git a/app/src/main/java/ro/code4/monitorizarevot/adapters/VisitedStationsAdapter.kt b/app/src/main/java/ro/code4/monitorizarevot/adapters/VisitedStationsAdapter.kt new file mode 100644 index 00000000..0efbb656 --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/adapters/VisitedStationsAdapter.kt @@ -0,0 +1,71 @@ +package ro.code4.monitorizarevot.adapters + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import ro.code4.monitorizarevot.R +import ro.code4.monitorizarevot.data.pojo.CountyAndPollingStation + +class VisitedStationsAdapter( + private val context: Context, + private val itemSelected: (CountyAndPollingStation) -> Unit +) : ListAdapter( + DIFF_CALLBACK +) { + + private val layoutInflater = LayoutInflater.from(context) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VisitedStationViewHolder { + return VisitedStationViewHolder( + layoutInflater.inflate( + R.layout.item_visited_section, + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: VisitedStationViewHolder, position: Int) { + val currentStation = getItem(position) + holder.textView.text = currentStation.displayName + holder.rowView.setOnClickListener { + itemSelected(currentStation) + } + } + + class VisitedStationViewHolder( + val rowView: View + ) : RecyclerView.ViewHolder(rowView) { + val textView: TextView = rowView.findViewById(R.id.visitedStation) + } + + private val CountyAndPollingStation.displayName: String + get() = countyOrNull()?.let { + context.getString(R.string.polling_station_visited, idPollingStation, it.name) + } ?: "Not Available" // TODO extract string? + + companion object { + val DIFF_CALLBACK = object : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: CountyAndPollingStation, + newItem: CountyAndPollingStation + ): Boolean = + oldItem == newItem + + override fun areContentsTheSame( + oldItem: CountyAndPollingStation, + newItem: CountyAndPollingStation + ): Boolean = + oldItem.idPollingStation == newItem.idPollingStation && + oldItem.observerArrivalTime == newItem.observerArrivalTime && + oldItem.countyCode == newItem.countyCode && + oldItem.county == newItem.county + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/adapters/delegates/NoteDelegate.kt b/app/src/main/java/ro/code4/monitorizarevot/adapters/delegates/NoteDelegate.kt index 9e668fda..52e0d34d 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/adapters/delegates/NoteDelegate.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/adapters/delegates/NoteDelegate.kt @@ -2,46 +2,57 @@ package ro.code4.monitorizarevot.adapters.delegates import android.view.LayoutInflater import android.view.View -import android.view.View.VISIBLE import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.card.MaterialCardView import com.hannesdorfmann.adapterdelegates4.AbsListItemAdapterDelegate import kotlinx.android.extensions.LayoutContainer import kotlinx.android.synthetic.main.item_note.* import ro.code4.monitorizarevot.R import ro.code4.monitorizarevot.adapters.helper.ListItem import ro.code4.monitorizarevot.adapters.helper.NoteListItem -import ro.code4.monitorizarevot.helper.formatDateTime +import ro.code4.monitorizarevot.data.model.Note +import ro.code4.monitorizarevot.helper.formatNoteDateTime + +class NoteDelegate( + private val noteSelectedListener: (Note) -> Unit +) : AbsListItemAdapterDelegate() { -class NoteDelegate : AbsListItemAdapterDelegate() { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder = ViewHolder( + noteSelectedListener, LayoutInflater.from(parent.context).inflate(R.layout.item_note, parent, false) ) override fun isForViewType( - item: ListItem, - items: MutableList, - position: Int - ): Boolean = - item is NoteListItem + item: ListItem, items: MutableList, position: Int + ): Boolean = item is NoteListItem override fun onBindViewHolder( - item: NoteListItem, - holder: ViewHolder, - payloads: MutableList + item: NoteListItem, holder: ViewHolder, payloads: MutableList ) { holder.bind(item) } - class ViewHolder(override val containerView: View) : - RecyclerView.ViewHolder(containerView), - LayoutContainer { + class ViewHolder( + private val noteSelectedListener: (Note) -> Unit, + override val containerView: View + ) : RecyclerView.ViewHolder(containerView), LayoutContainer { private lateinit var item: NoteListItem + private val noteRowContainer = + containerView.findViewById(R.id.noteRowContainer) fun bind(noteListItem: NoteListItem) { item = noteListItem - + noteRowContainer.setOnClickListener { noteSelectedListener(noteListItem.note) } + formAndQuestionIdentifier.text = + if (item.note.formCode != null && item.note.questionCode != null) { + containerView.context.getString( + R.string.note_details_codes, item.note.formCode, item.note.questionCode + ) + } else { + "" + } with(item.note) { /* questionId?.let { noteQuestionText.visibility = VISIBLE @@ -49,7 +60,7 @@ class NoteDelegate : AbsListItemAdapterDelegate = arrayOf(MIGRATION_1_2, MIGRATION_2_3) + /** + * This migration changes the database to add the new orderNumber for sections, questions and answers. + */ + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE section ADD COLUMN `orderNumber` INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE question ADD COLUMN `orderNumber` INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE answer ADD COLUMN `orderNumber` INTEGER NOT NULL DEFAULT 0") + } + } + + /** + * This migration changes the database to add the form and question codes to the Note entity. + */ + val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE note ADD COLUMN `formCode` TEXT DEFAULT NULL") + database.execSQL("ALTER TABLE note ADD COLUMN `questionCode` TEXT DEFAULT NULL") + } + } + + val ALL: Array = arrayOf(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/dao/FormsDao.kt b/app/src/main/java/ro/code4/monitorizarevot/data/dao/FormsDao.kt index 6df47852..37ade4d0 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/data/dao/FormsDao.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/data/dao/FormsDao.kt @@ -5,6 +5,7 @@ import androidx.room.* import androidx.room.OnConflictStrategy.REPLACE import io.reactivex.Completable import io.reactivex.Maybe +import io.reactivex.Observable import ro.code4.monitorizarevot.data.model.Answer import ro.code4.monitorizarevot.data.model.FormDetails import ro.code4.monitorizarevot.data.model.Question @@ -14,7 +15,7 @@ import ro.code4.monitorizarevot.data.model.answers.SelectedAnswer import ro.code4.monitorizarevot.data.pojo.AnsweredQuestionPOJO import ro.code4.monitorizarevot.data.pojo.FormWithSections import ro.code4.monitorizarevot.data.pojo.SectionWithQuestions -import java.util.* + @Dao interface FormsDao { @@ -64,21 +65,21 @@ interface FormsDao { fun getAnswersFor( countyCode: String, pollingStationNumber: Int - ): LiveData> + ): Observable> @Query("SELECT * FROM form_details ORDER BY `order`") - fun getFormsWithSections(): LiveData> + fun getFormsWithSections(): Observable> - @Query("SELECT * FROM section where formId=:formId") - fun getSectionsWithQuestions(formId: Int): LiveData> + @Query("SELECT * FROM section where formId=:formId ORDER BY orderNumber") + fun getSectionsWithQuestions(formId: Int): Observable> @Query("SELECT * FROM answered_question WHERE countyCode=:countyCode AND pollingStationNumber=:pollingStationNumber AND formId=:formId") fun getAnswersForForm( countyCode: String?, pollingStationNumber: Int, formId: Int - ): LiveData> + ): Observable> @Query("SELECT * FROM answered_question WHERE countyCode=:countyCode AND pollingStationNumber=:pollingStationNumber AND formId=:formId AND synced=:synced") fun getNotSyncedQuestionsForForm( @@ -125,4 +126,12 @@ interface FormsDao { @Query("SELECT COUNT(*) FROM answered_question WHERE synced=:synced") fun getCountOfNotSyncedQuestions(synced: Boolean = false): LiveData + @Query("DELETE FROM answered_question") + fun deleteAllAnswers() + @Query("DELETE FROM question") + fun deleteAllQuestions() + @Query("DELETE FROM section") + fun deleteAllSections() + @Query("DELETE FROM form_details") + fun deleteAllForms() } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/dao/NoteDao.kt b/app/src/main/java/ro/code4/monitorizarevot/data/dao/NoteDao.kt index 81fc2bab..b9212272 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/data/dao/NoteDao.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/data/dao/NoteDao.kt @@ -3,6 +3,7 @@ package ro.code4.monitorizarevot.data.dao import androidx.lifecycle.LiveData import androidx.room.* import io.reactivex.Maybe +import io.reactivex.Observable import ro.code4.monitorizarevot.data.model.Note @Dao @@ -20,12 +21,25 @@ interface NoteDao { questionId: Int? = null ): LiveData> + @Query("SELECT * FROM note WHERE countyCode=:countyCode AND pollingStationNumber=:pollingStationNumber AND questionId=:questionId ORDER BY date DESC") + fun getNotesForQuestionAsObservable( + countyCode: String, + pollingStationNumber: Int, + questionId: Int? = null + ): Observable> + @Query("SELECT * FROM note WHERE countyCode=:countyCode AND pollingStationNumber=:pollingStationNumber ORDER BY date DESC") fun getNotes(countyCode: String, pollingStationNumber: Int): LiveData> + @Query("SELECT * FROM note WHERE countyCode=:countyCode AND pollingStationNumber=:pollingStationNumber ORDER BY date DESC") + fun getNotesAsObservable(countyCode: String, pollingStationNumber: Int): Observable> + @Query("SELECT * FROM note WHERE synced=:synced") fun getNotSyncedNotes(synced: Boolean = false): Maybe> @Query("SELECT COUNT(*) FROM note WHERE synced =:synced") fun getCountOfNotSyncedNotes(synced: Boolean = false): LiveData + + @Query("DELETE FROM note") + fun deleteAll() } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/dao/PollingStationDao.kt b/app/src/main/java/ro/code4/monitorizarevot/data/dao/PollingStationDao.kt index 85ebd337..3e18dd94 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/data/dao/PollingStationDao.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/data/dao/PollingStationDao.kt @@ -3,7 +3,9 @@ package ro.code4.monitorizarevot.data.dao import androidx.lifecycle.LiveData import androidx.room.* import io.reactivex.Maybe +import io.reactivex.Observable import ro.code4.monitorizarevot.data.model.PollingStation +import ro.code4.monitorizarevot.data.pojo.CountyAndPollingStation import ro.code4.monitorizarevot.data.pojo.PollingStationInfo @Dao @@ -28,4 +30,10 @@ interface PollingStationDao { @Query("SELECT COUNT(*) FROM polling_station WHERE synced =:synced") fun getCountOfNotSyncedPollingStations(synced: Boolean = false): LiveData + + @Query("DELETE FROM polling_station") + fun deleteAll() + + @Query("SELECT * FROM polling_station WHERE observerArrivalTime NOT NULL") + fun getVisitedPollingStations(): Observable> } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/model/Answer.kt b/app/src/main/java/ro/code4/monitorizarevot/data/model/Answer.kt index ce855e3c..b422c30a 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/data/model/Answer.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/data/model/Answer.kt @@ -35,6 +35,9 @@ class Answer { @Ignore var value: String? = null + @Expose + var orderNumber = 0 + override fun equals(other: Any?): Boolean = other is Answer && idOption == other.idOption && text == other.text && isFreeText == other.isFreeText && questionId == other.questionId && diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/model/Note.kt b/app/src/main/java/ro/code4/monitorizarevot/data/model/Note.kt index 79d65cba..223a88e4 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/data/model/Note.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/data/model/Note.kt @@ -4,6 +4,7 @@ import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey +import org.parceler.Parcel import java.util.* @Entity( @@ -19,6 +20,7 @@ import java.util.* )], indices = [Index(value = ["countyCode", "pollingStationNumber", "questionId"], unique = false)] ) +@Parcel(Parcel.Serialization.FIELD) class Note { @PrimaryKey(autoGenerate = true) var id: Int = 0 @@ -32,10 +34,15 @@ class Note { var date: Date = Date() lateinit var countyCode: String + var pollingStationNumber = 0 var synced = false + var formCode: String? = null + + var questionCode: String? = null + override fun equals(other: Any?): Boolean = other is Note && other.id == id diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/model/PollingStation.kt b/app/src/main/java/ro/code4/monitorizarevot/data/model/PollingStation.kt index 01bb9e41..01253c38 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/data/model/PollingStation.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/data/model/PollingStation.kt @@ -65,4 +65,35 @@ class PollingStation() { this.observerLeaveTime = departureTime } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as PollingStation + + if (id != other.id) return false + if (countyCode != other.countyCode) return false + if (idPollingStation != other.idPollingStation) return false + if (urbanArea != other.urbanArea) return false + if (isPollingStationPresidentFemale != other.isPollingStationPresidentFemale) return false + if (observerArrivalTime != other.observerArrivalTime) return false + if (observerLeaveTime != other.observerLeaveTime) return false + if (synced != other.synced) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + countyCode.hashCode() + result = 31 * result + idPollingStation + result = 31 * result + urbanArea.hashCode() + result = 31 * result + isPollingStationPresidentFemale.hashCode() + result = 31 * result + (observerArrivalTime?.hashCode() ?: 0) + result = 31 * result + (observerLeaveTime?.hashCode() ?: 0) + result = 31 * result + synced.hashCode() + return result + } + + } diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/model/Question.kt b/app/src/main/java/ro/code4/monitorizarevot/data/model/Question.kt index ff2bb844..56500e16 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/data/model/Question.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/data/model/Question.kt @@ -47,6 +47,9 @@ class Question { var hasNotes = false + @Expose + var orderNumber = 0 + override fun equals(other: Any?): Boolean = other is Question && id == other.id && text == other.text && code == other.code && questionType == other.questionType && diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/model/Section.kt b/app/src/main/java/ro/code4/monitorizarevot/data/model/Section.kt index 5fd51aaf..4911d0b3 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/data/model/Section.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/data/model/Section.kt @@ -35,6 +35,9 @@ class Section { var formId: Int = -1 + @Expose + var orderNumber = 0 + override fun equals(other: Any?): Boolean { if (other !is Section) { return false diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/model/User.kt b/app/src/main/java/ro/code4/monitorizarevot/data/model/User.kt index 51954ec4..2b5942ff 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/data/model/User.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/data/model/User.kt @@ -5,5 +5,6 @@ import com.google.gson.annotations.Expose class User( @field:Expose var user: String, @field:Expose var password: String, - @field:Expose var uniqueId: String + @field:Expose var fcmToken: String, + @field:Expose var channelName: String = "Firebase" ) \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/model/response/PostNoteResponse.kt b/app/src/main/java/ro/code4/monitorizarevot/data/model/response/PostNoteResponse.kt new file mode 100644 index 00000000..7ea6f51a --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/data/model/response/PostNoteResponse.kt @@ -0,0 +1,8 @@ +package ro.code4.monitorizarevot.data.model.response + +import com.google.gson.annotations.Expose + +class PostNoteResponse { + @Expose + lateinit var filesAddress: List +} \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/model/response/VersionResponse.kt b/app/src/main/java/ro/code4/monitorizarevot/data/model/response/VersionResponse.kt index 9677fac1..ac345c00 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/data/model/response/VersionResponse.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/data/model/response/VersionResponse.kt @@ -5,8 +5,10 @@ import org.parceler.Parcel import ro.code4.monitorizarevot.data.model.FormDetails @Parcel(Parcel.Serialization.FIELD) -class VersionResponse { +open class VersionResponse { @Expose lateinit var formVersions: List -} \ No newline at end of file +} + +class ErrorVersionResponse(exception: Throwable?) : VersionResponse() \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/pojo/CountyAndPollingStation.kt b/app/src/main/java/ro/code4/monitorizarevot/data/pojo/CountyAndPollingStation.kt new file mode 100644 index 00000000..b8496162 --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/data/pojo/CountyAndPollingStation.kt @@ -0,0 +1,35 @@ +package ro.code4.monitorizarevot.data.pojo + +import androidx.room.Relation +import ro.code4.monitorizarevot.data.model.County + +class CountyAndPollingStation { + var idPollingStation: Int = 0 + lateinit var countyCode: String + lateinit var observerArrivalTime: String + + @Relation(parentColumn = "countyCode", entityColumn = "code", entity = County::class) + lateinit var county: List + + fun countyOrNull(): County? = + if (::county.isInitialized && county.isNotEmpty()) county[0] else null + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as CountyAndPollingStation + if (idPollingStation != other.idPollingStation) return false + if (countyCode != other.countyCode) return false + if (observerArrivalTime != other.observerArrivalTime) return false + if (county != other.county) return false + return true + } + + override fun hashCode(): Int { + var result = idPollingStation + result = 31 * result + countyCode.hashCode() + result = 31 * result + observerArrivalTime.hashCode() + result = 31 * result + county.hashCode() + return result + } +} diff --git a/app/src/main/java/ro/code4/monitorizarevot/data/pojo/FormWithSections.kt b/app/src/main/java/ro/code4/monitorizarevot/data/pojo/FormWithSections.kt index bff01031..c5aa1cf1 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/data/pojo/FormWithSections.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/data/pojo/FormWithSections.kt @@ -7,7 +7,7 @@ import ro.code4.monitorizarevot.data.model.FormDetails import ro.code4.monitorizarevot.data.model.Section -class FormWithSections { +open class FormWithSections { @Embedded lateinit var form: FormDetails @@ -17,7 +17,6 @@ class FormWithSections { @Ignore var noAnsweredQuestions: Int = 0 - override fun equals(other: Any?): Boolean = other is FormWithSections && form == other.form && sections.map { sections } == other.sections.map { sections } && noAnsweredQuestions == other.noAnsweredQuestions @@ -27,4 +26,6 @@ class FormWithSections { result = 31 * result + noAnsweredQuestions return result } -} \ No newline at end of file +} + +class ErrorFormWithSections(exception: Throwable) : FormWithSections() \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/helper/Constants.kt b/app/src/main/java/ro/code4/monitorizarevot/helper/Constants.kt index fba0477c..4db52536 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/helper/Constants.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/helper/Constants.kt @@ -5,12 +5,16 @@ object Constants { const val DATE_TIME_FORMAT = "dd.MM.yyyy HH:mm" const val DATE_FORMAT = "yyyy-MM-dd HH:mm:ss" const val DATE_FORMAT_SIMPLE = "dd.MM.yyyy" + const val DATA_NOTE_FORMAT = "dd/MM HH:mm" const val FORM = "form" const val QUESTION = "question" + const val NOTE = "note" + const val FORM_QUESTION_CODES = "form_question_codes" const val REQUEST_CODE_RECORD_VIDEO = 1001 const val REQUEST_CODE_TAKE_PHOTO = 1002 const val REQUEST_CODE_GALLERY = 1003 + const val FILES_PATHS_SEPARATOR = "|" const val TYPE_MULTI_CHOICE = 0 const val TYPE_SINGLE_CHOICE = 1 @@ -18,7 +22,13 @@ object Constants { const val TYPE_MULTI_CHOICE_DETAILS = 3 const val REMOTE_CONFIG_FILTER_DIASPORA_FORMS = "filter_diaspora_forms" - + const val REMOTE_CONFIG_CONTACT_EMAIL = "contact_email" + const val REMOTE_CONFIG_PRIVACY_POLICY_URL = "privacy_policy_url" + const val REMOTE_CONFIG_OBSERVER_GUIDE_URL = "observer_guide_url" + const val REMOTE_CONFIG_SAFETY_GUIDE_URL = "safety_guide_url" + const val REMOTE_CONFIG_OBSERVER_FEEDBACK_URL = "observer_feedback_url" + const val REMOTE_CONFIG_ROUND_START_TIMESTAMP = "round_start_time" + const val PUSH_NOTIFICATION_TITLE = "title" const val PUSH_NOTIFICATION_BODY = "body" } diff --git a/app/src/main/java/ro/code4/monitorizarevot/helper/FileUtils.kt b/app/src/main/java/ro/code4/monitorizarevot/helper/FileUtils.kt index 6482e35f..26a242f8 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/helper/FileUtils.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/helper/FileUtils.kt @@ -1,165 +1,66 @@ package ro.code4.monitorizarevot.helper -import android.content.ContentUris +import android.content.ContentResolver import android.content.Context -import android.database.Cursor -import android.database.DatabaseUtils import android.net.Uri -import android.provider.DocumentsContract -import android.provider.MediaStore -import android.util.Log +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import java.io.File +import java.io.IOException /** - * File utilities, thanks to https://github.com/epforgpl/monitorizare-vot-android/ + * File utilities */ object FileUtils { - /** TAG for log messages. */ - internal const val TAG = "FileUtils" - private const val DEBUG = false // Set to true to enable logging - - /** - * Get the value of the data column for this Uri. This is useful for - * MediaStore Uris, and other file-based ContentProviders. - * - * @param context The context. - * @param uri The Uri to query. - * @param selection (Optional) Filter used in the query. - * @param selectionArgs (Optional) Selection arguments used in the query. - * @return The value of the _data column, which is typically a file path. - * @author paulburke - */ - fun getDataColumn( - context: Context, uri: Uri?, selection: String?, - selectionArgs: Array? - ): String? { - - var cursor: Cursor? = null - val column = "_data" - val projection = arrayOf(column) - uri?.let { - try { - cursor = - context.contentResolver.query(it, projection, selection, selectionArgs, null) - cursor?.let { curs -> - @Suppress("ConstantConditionIf") - if (curs.moveToFirst()) { - if (DEBUG) - DatabaseUtils.dumpCursor(curs) - - val columnIndex = curs.getColumnIndexOrThrow(column) - return curs.getString(columnIndex) - } + private const val UPLOADS_DIR_NAME = "uploads" + + @Throws(IOException::class) + internal fun copyFileToCache(context: Context, uri: Uri): File = + with(context) { + val directory = File(filesDir, UPLOADS_DIR_NAME) + directory.mkdirs() + File( + directory, + getFileName(uri) ?: generateTempFileName(uri) + ).also { f -> + contentResolver.openInputStream(uri)?.use { + f.createNewFile() + it.copyTo(f.outputStream()) } - } finally { - cursor?.close() } } - return null - } - - /** - * Get a file path from a Uri. This will get the the path for Storage Access - * Framework Documents, as well as the _data field for the MediaStore and - * other file-based ContentProviders.

- *

- * Callers should check whether the path is local before assuming it - * represents a local file. - * - */ - fun getPath(context: Context, uri: Uri): String? { - - @Suppress("ConstantConditionIf") - if (DEBUG) - Log.d( - "$TAG File -", - "Authority: " + uri.authority + - ", Fragment: " + uri.fragment + - ", Port: " + uri.port + - ", Query: " + uri.query + - ", Scheme: " + uri.scheme + - ", Host: " + uri.host + - ", Segments: " + uri.pathSegments.toString() - ) - - // DocumentProvider - if (isExternalStorageDocument(uri)) { - val docId = DocumentsContract.getDocumentId(uri) - val split = - docId.split((":").toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - val type = split[0] - - if ("primary".equals(type, ignoreCase = true)) { - return "${context.getExternalFilesDir(null)}/${split[1]}" - } - - // TODO handle non-primary volumes - } else if (isDownloadsDocument(uri)) { - - val id = DocumentsContract.getDocumentId(uri) - val contentUri = ContentUris.withAppendedId( - Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id) - ) - return getDataColumn(context, contentUri, null, null) - } else if (isMediaDocument(uri)) { - val docId = DocumentsContract.getDocumentId(uri) - val split = - docId.split((":").toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - val type = split[0] - - var contentUri: Uri? = null - @Suppress("ConstantConditionIf") - when (type) { - "image" -> contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI - "video" -> contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI - "audio" -> contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI - } - - val selection = "_id=?" - val selectionArgs = arrayOf(split[1]) - - return getDataColumn(context, contentUri, selection, selectionArgs) - }// MediaProvider - // DownloadsProvider - // File - // MediaStore (and general) - - return null - } - - /** - * @param uri The Uri to check. - * @return Whether the Uri authority is DownloadsProvider. - * @author paulburke - */ - fun isDownloadsDocument(uri: Uri): Boolean { - return "com.android.providers.downloads.documents" == uri.authority - } + private fun Context.getFileName(uri: Uri): String? = + when (uri.scheme) { + ContentResolver.SCHEME_FILE -> uri.path?.let { File(it).name } + ContentResolver.SCHEME_CONTENT -> getCursorContent(uri) + else -> null + } - /** - * @param uri The Uri to check. - * @return Whether the Uri authority is ExternalStorageProvider. - * @author paulburke - */ - fun isExternalStorageDocument(uri: Uri): Boolean { - return "com.android.externalstorage.documents" == uri.authority - } + private fun Context.getCursorContent(uri: Uri): String? = + runCatching { + contentResolver.query(uri, null, null, null, null) + ?.let { cursor -> + cursor.run { + if (moveToFirst()) { + getString(getColumnIndex(OpenableColumns.DISPLAY_NAME))?.let { name -> + "${System.currentTimeMillis()}_${name}" + } + } else { + null + } + }.also { cursor.close() } + } + }.getOrNull() - /** - * @param uri The Uri to check. - * @return Whether the Uri authority is Google Photos. - */ - fun isGooglePhotosUri(uri: Uri): Boolean { - return "com.google.android.apps.photos.content" == uri.authority - } + private fun Context.generateTempFileName(uri: Uri): String = + "mon_vot_${System.currentTimeMillis()}.${getMimeType(contentResolver, uri)}" - /** - * @param uri The Uri to check. - * @return Whether the Uri authority is MediaProvider. - * @author paulburke - */ - fun isMediaDocument(uri: Uri): Boolean { - return "com.android.providers.media.documents" == uri.authority - } + private fun getMimeType(contentResolver: ContentResolver, uri: Uri): String? = + if (uri.scheme == ContentResolver.SCHEME_CONTENT) { + MimeTypeMap.getSingleton().getExtensionFromMimeType(contentResolver.getType(uri)) + } else { + MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + } } diff --git a/app/src/main/java/ro/code4/monitorizarevot/helper/PreferenceManager.kt b/app/src/main/java/ro/code4/monitorizarevot/helper/PreferenceManager.kt index 1044c602..1a0766d5 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/helper/PreferenceManager.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/helper/PreferenceManager.kt @@ -9,10 +9,12 @@ const val PREFS_POLLING_STATION_NUMBER = "PREFS_POLLING_STATION_NUMBER" const val ONBOARDING_COMPLETED = "ONBOARDING_COMPLETED" const val POLLING_STATION_CONFIG_COMPLETED = "POLLING_STATION_CONFIG_COMPLETED" const val PREFS_LANGUAGE_CODE = "PREFS_LANGUAGE_CODE" - +const val PREFS_LAST_DB_RESET_TIMESTAMP = "PREFS_LAST_DB_RESET_TIMESTAMP" +const val HAS_SELECTED_STATIONS = "HAS_SELECTED_STATIONS" fun SharedPreferences.getString(key: String): String? = getString(key, null) fun SharedPreferences.getInt(key: String): Int = getInt(key, 0) +fun SharedPreferences.getLong(key: String): Long = getLong(key, 0) fun SharedPreferences.putString(key: String, value: String?) { val editor = edit() @@ -26,6 +28,12 @@ fun SharedPreferences.putInt(key: String, value: Int) { editor.apply() } +fun SharedPreferences.putLong(key: String, value: Long) { + val editor = edit() + editor.putLong(key, value) + editor.apply() +} + fun SharedPreferences.putBoolean(key: String, value: Boolean = true) { val editor = edit() editor.putBoolean(key, value) @@ -56,4 +64,24 @@ fun SharedPreferences.completedOnboarding() = putBoolean(ONBOARDING_COMPLETED) fun SharedPreferences.getLocaleCode(): String = getString(PREFS_LANGUAGE_CODE, BuildConfig.PREFERRED_LOCALE) ?: BuildConfig.PREFERRED_LOCALE -fun SharedPreferences.setLocaleCode(code: String) = putString(PREFS_LANGUAGE_CODE, code) \ No newline at end of file +fun SharedPreferences.setLocaleCode(code: String) = putString(PREFS_LANGUAGE_CODE, code) + +fun SharedPreferences.getLastDbResetTimestamp() = getLong(PREFS_LAST_DB_RESET_TIMESTAMP) +fun SharedPreferences.setLastDbResetTimestamp(value: Long) = putLong(PREFS_LAST_DB_RESET_TIMESTAMP, value) + +fun SharedPreferences.getHasSelectedStations() = getBoolean(HAS_SELECTED_STATIONS, false) +fun SharedPreferences.setHasSelectedStations(value: Boolean) = putBoolean(HAS_SELECTED_STATIONS, value) + +fun SharedPreferences.clearUserPrefs() = run { + completedPollingStationConfig(false) + removeCurrentLocationPrefs() + setHasSelectedStations(false) + deleteToken() +} + +private fun SharedPreferences.removeCurrentLocationPrefs() { + val editor = edit() + editor.remove(PREFS_COUNTY_CODE) + editor.remove(PREFS_POLLING_STATION_NUMBER) + editor.apply() +} \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/helper/Utils.kt b/app/src/main/java/ro/code4/monitorizarevot/helper/Utils.kt index ef407c0a..a05ea43d 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/helper/Utils.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/helper/Utils.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.graphics.Rect import android.graphics.Typeface import android.net.ConnectivityManager import android.net.NetworkCapabilities @@ -15,8 +16,10 @@ import android.provider.MediaStore import android.text.* import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan +import android.view.MotionEvent import android.view.View import android.view.inputmethod.InputMethodManager +import android.widget.EditText import androidx.annotation.IdRes import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat @@ -26,6 +29,7 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.recyclerview.widget.RecyclerView +import com.google.firebase.remoteconfig.FirebaseRemoteConfig import com.google.gson.Gson import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.Disposable @@ -34,10 +38,12 @@ import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody import ro.code4.monitorizarevot.BuildConfig import ro.code4.monitorizarevot.R +import ro.code4.monitorizarevot.data.model.County import ro.code4.monitorizarevot.helper.Constants.REQUEST_CODE_RECORD_VIDEO import ro.code4.monitorizarevot.helper.Constants.REQUEST_CODE_TAKE_PHOTO import ro.code4.monitorizarevot.interfaces.ExcludeFromCodeCoverage import ro.code4.monitorizarevot.ui.section.PollingStationActivity +import ro.code4.monitorizarevot.ui.section.VisitedPollingStationsActivity import java.io.File import java.text.SimpleDateFormat import java.util.* @@ -78,8 +84,17 @@ fun FragmentManager.replaceFragment( ft.commit() } -fun AppCompatActivity.changePollingStation() { - startActivity(Intent(this, PollingStationActivity::class.java)) +fun AppCompatActivity.changePollingStation(county: County? = null, pollingStationId: Int = -1) { + val intent = Intent(this, PollingStationActivity::class.java) + if (county != null && pollingStationId > 0) { + intent.putExtra(PollingStationActivity.EXTRA_POLLING_STATION_ID, pollingStationId) + intent.putExtra(PollingStationActivity.EXTRA_COUNTY_NAME, county.name) + } + startActivity(intent) +} + +fun AppCompatActivity.showVisitedPollingStations() { + startActivity(Intent(this, VisitedPollingStationsActivity::class.java)) } fun Calendar.updateTime(year: Int, month: Int, dayOfMonth: Int, hourOfDay: Int, minute: Int) { @@ -113,6 +128,11 @@ fun Date.formatDateTime(): String { return formatter.format(this) } +fun Date.formatNoteDateTime(): String { + val formatter = SimpleDateFormat(Constants.DATA_NOTE_FORMAT, Locale.getDefault()) + return formatter.format(this) +} + fun String?.getDate(): Long? { if (this == null) { return null @@ -309,6 +329,7 @@ fun Fragment.openGallery() { intent.type = "image/*" val extraMime = arrayOf("image/*", "video/*") intent.putExtra(Intent.EXTRA_MIME_TYPES, extraMime) + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.resolveActivity(activity!!.packageManager)?.also { startActivityForResult( @@ -442,3 +463,40 @@ fun Context.browse(url: String, newTask: Boolean = false): Boolean { return false } } + +@Suppress("NOTHING_TO_INLINE") +internal inline fun FirebaseRemoteConfig?.getStringOrDefault(key: String, defaultValue: String) = + this?.getString(key).takeUnless { + it == FirebaseRemoteConfig.DEFAULT_VALUE_FOR_STRING + } ?: defaultValue + +/* + * Hide software keyboard if user taps outside the EditText + * use inside override fun dispatchTouchEvent() + */ +fun collapseKeyboardIfFocusOutsideEditText( + motionEvent: MotionEvent, + oldFocusedView: View, + newFocusedView: View +) { + if (motionEvent.action == MotionEvent.ACTION_UP) { + if (newFocusedView == oldFocusedView) { + + val srcCoordinates = IntArray(2) + oldFocusedView.getLocationOnScreen(srcCoordinates) + + val rect = Rect(srcCoordinates[0], srcCoordinates[1], srcCoordinates[0] + + oldFocusedView.width, srcCoordinates[1] + oldFocusedView.height) + + if (rect.contains(motionEvent.x.toInt(), motionEvent.y.toInt())) + return + } else if (newFocusedView is EditText) { + // If new focus is other EditText then will not collapse + return + } + + // Collapse the keyboard from activity + ContextCompat.getSystemService(newFocusedView.context, InputMethodManager::class.java) + ?.hideSoftInputFromWindow(newFocusedView.windowToken, 0) + } +} diff --git a/app/src/main/java/ro/code4/monitorizarevot/helper/WebClient.kt b/app/src/main/java/ro/code4/monitorizarevot/helper/WebClient.kt deleted file mode 100644 index 51f22ce5..00000000 --- a/app/src/main/java/ro/code4/monitorizarevot/helper/WebClient.kt +++ /dev/null @@ -1,33 +0,0 @@ -package ro.code4.monitorizarevot.helper - -import android.graphics.Bitmap -import android.webkit.WebResourceRequest -import android.webkit.WebView -import android.webkit.WebViewClient -import ro.code4.monitorizarevot.BuildConfig - -class WebClient(private val listener: WebLoaderListener) : WebViewClient() { - override fun onPageFinished(view: WebView, url: String?) { - super.onPageFinished(view, url) - if (view.title.isNullOrEmpty()) { - view.reload() - return - } - listener.onPageFinished() - } - - override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) { - listener.onLoading() - super.onPageStarted(view, url, favicon) - } - - override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - return request?.url?.path?.contains(BuildConfig.GUIDE_URL) == false - - } - - interface WebLoaderListener { - fun onPageFinished() - fun onLoading() - } -} \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/modules/Modules.kt b/app/src/main/java/ro/code4/monitorizarevot/modules/Modules.kt index 8cf3f6f0..0740c4e7 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/modules/Modules.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/modules/Modules.kt @@ -21,12 +21,14 @@ import ro.code4.monitorizarevot.BuildConfig.DEBUG import ro.code4.monitorizarevot.data.AppDatabase import ro.code4.monitorizarevot.helper.getToken import ro.code4.monitorizarevot.repositories.Repository +import ro.code4.monitorizarevot.ui.section.VisitedPollingStationsViewModel import ro.code4.monitorizarevot.ui.forms.FormsViewModel import ro.code4.monitorizarevot.ui.forms.questions.QuestionsDetailsViewModel import ro.code4.monitorizarevot.ui.forms.questions.QuestionsViewModel import ro.code4.monitorizarevot.ui.guide.GuideViewModel import ro.code4.monitorizarevot.ui.login.LoginViewModel import ro.code4.monitorizarevot.ui.main.MainViewModel +import ro.code4.monitorizarevot.ui.notes.NoteDetailsViewModel import ro.code4.monitorizarevot.ui.notes.NoteViewModel import ro.code4.monitorizarevot.ui.onboarding.OnboardingViewModel import ro.code4.monitorizarevot.ui.section.PollingStationViewModel @@ -106,10 +108,12 @@ val viewModelsModule = module { viewModel { MainViewModel() } viewModel { PollingStationViewModel() } viewModel { PollingStationSelectionViewModel() } + viewModel { VisitedPollingStationsViewModel(get()) } viewModel { FormsViewModel() } viewModel { QuestionsViewModel() } viewModel { QuestionsDetailsViewModel() } viewModel { NoteViewModel() } + viewModel { NoteDetailsViewModel() } viewModel { GuideViewModel() } viewModel { SplashScreenViewModel() } } diff --git a/app/src/main/java/ro/code4/monitorizarevot/repositories/Repository.kt b/app/src/main/java/ro/code4/monitorizarevot/repositories/Repository.kt index 7852f7dd..3f80bf91 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/repositories/Repository.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/repositories/Repository.kt @@ -1,8 +1,11 @@ package ro.code4.monitorizarevot.repositories import android.annotation.SuppressLint +import android.os.AsyncTask import android.util.Log import androidx.lifecycle.LiveData +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import io.reactivex.Observable import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers @@ -19,12 +22,12 @@ import ro.code4.monitorizarevot.data.AppDatabase import ro.code4.monitorizarevot.data.model.* import ro.code4.monitorizarevot.data.model.answers.AnsweredQuestion import ro.code4.monitorizarevot.data.model.answers.SelectedAnswer +import ro.code4.monitorizarevot.data.model.response.ErrorVersionResponse import ro.code4.monitorizarevot.data.model.response.LoginResponse +import ro.code4.monitorizarevot.data.model.response.PostNoteResponse import ro.code4.monitorizarevot.data.model.response.VersionResponse -import ro.code4.monitorizarevot.data.pojo.AnsweredQuestionPOJO -import ro.code4.monitorizarevot.data.pojo.FormWithSections -import ro.code4.monitorizarevot.data.pojo.PollingStationInfo -import ro.code4.monitorizarevot.data.pojo.SectionWithQuestions +import ro.code4.monitorizarevot.data.pojo.* +import ro.code4.monitorizarevot.helper.Constants import ro.code4.monitorizarevot.helper.createMultipart import ro.code4.monitorizarevot.services.ApiInterface import ro.code4.monitorizarevot.services.LoginInterface @@ -48,12 +51,11 @@ class Repository : KoinComponent { retrofit.create(ApiInterface::class.java) } + private val postTypeToken = object : TypeToken() {}.type + private var syncInProgress = false fun login(user: User): Observable = loginInterface.login(user) - fun registerForNotification(token: String): Observable = - loginInterface.registerForNotification(token) - fun getCounties(): Single> { val observableApi = apiInterface.getCounties() val observableDb = db.countyDao().getAll().take(1).single(emptyList()) @@ -62,7 +64,9 @@ class Repository : KoinComponent { observableApi.onErrorReturnItem(emptyList()), BiFunction, List, List> { dbCounties, apiCounties -> val areAllApiCountiesInDb = apiCounties.all(dbCounties::contains) - apiCounties.forEach { it.name = it.name.toLowerCase(Locale.getDefault()).capitalize() } + apiCounties.forEach { + it.name = it.name.toLowerCase(Locale.getDefault()).capitalize() + } return@BiFunction when { apiCounties.isNotEmpty() && !areAllApiCountiesInDb -> { db.countyDao().deleteAll() @@ -113,27 +117,23 @@ class Repository : KoinComponent { fun getAnswers( countyCode: String, pollingStationNumber: Int - ): LiveData> = + ): Observable> = db.formDetailsDao().getAnswersFor(countyCode, pollingStationNumber) - fun getFormsWithQuestions(): LiveData> = + fun getFormsWithQuestions(): Observable> = db.formDetailsDao().getFormsWithSections() - fun getSectionsWithQuestions(formId: Int): LiveData> = + fun getSectionsWithQuestions(formId: Int): Observable> = db.formDetailsDao().getSectionsWithQuestions(formId) fun getForms(): Observable { - - val observableDb = db.formDetailsDao().getAllForms().toObservable() - + val observableDb = db.formDetailsDao().getFormsWithSections() val observableApi = apiInterface.getForms() - return Observable.zip( - observableDb.onErrorReturn { null }, - observableApi.onErrorReturn { null }, - BiFunction?, VersionResponse?, Unit> { dbFormDetails, response -> + observableDb.onErrorReturn { listOf(ErrorFormWithSections(it)) }, + observableApi.onErrorReturn { ErrorVersionResponse(it) }, + BiFunction, VersionResponse, Unit> { dbFormDetails, response -> processFormDetailsData(dbFormDetails, response) - }) } @@ -164,29 +164,32 @@ class Repository : KoinComponent { } private fun processFormDetailsData( - dbFormDetails: List?, - response: VersionResponse? + dbFormDetails: List, + response: VersionResponse ) { - if (response == null) { + if (response is ErrorVersionResponse) { return } val apiFormDetails = response.formVersions - if (dbFormDetails == null || dbFormDetails.isEmpty()) { + if ((dbFormDetails.size == 1 && dbFormDetails[0] is ErrorFormWithSections) + || dbFormDetails.isEmpty() + ) { saveFormDetails(apiFormDetails) return } if (apiFormDetails.size < dbFormDetails.size) { - dbFormDetails.minus(apiFormDetails).also { diff -> + dbFormDetails.map { it.form }.minus(apiFormDetails).also { diff -> if (diff.isNotEmpty()) { deleteFormDetails(*diff.map { it }.toTypedArray()) } } } apiFormDetails.forEach { apiForm -> - val dbForm = dbFormDetails.find { it.id == apiForm.id } - if (dbForm != null && (apiForm.formVersion != dbForm.formVersion || - apiForm.order != dbForm.order)) { - deleteFormDetails(dbForm) + val dbForm = dbFormDetails.find { it.form.id == apiForm.id } + if (dbForm != null && (apiForm.formVersion != dbForm.form.formVersion || + apiForm.order != dbForm.form.order) + ) { + deleteFormDetails(dbForm.form) saveFormDetails(apiForm) } if (dbForm == null) { @@ -219,16 +222,19 @@ class Repository : KoinComponent { countyCode: String?, pollingStationNumber: Int, formId: Int - ): LiveData> { + ): Observable> { return db.formDetailsDao().getAnswersForForm(countyCode, pollingStationNumber, formId) } @SuppressLint("CheckResult") fun saveAnsweredQuestion(answeredQuestion: AnsweredQuestion, answers: List) { - Observable.create { + Observable.fromCallable { db.formDetailsDao().insertAnsweredQuestion(answeredQuestion, answers) + true }.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()).subscribe({}, { + .observeOn(AndroidSchedulers.mainThread()).subscribe({ + Log.d(TAG, "Saving answered question: $answeredQuestion(answers: $answers)") + }, { Log.i(TAG, it.message.orEmpty()) }) } @@ -246,14 +252,20 @@ class Repository : KoinComponent { fun syncAnswers(countyCode: String, pollingStationNumber: Int, formId: Int) { db.formDetailsDao().getNotSyncedQuestionsForForm(countyCode, pollingStationNumber, formId) .toObservable() - .subscribeOn(Schedulers.io()).flatMap { - syncAnswers(it) - }.observeOn(AndroidSchedulers.mainThread()).subscribe({ - Observable.create { - db.formDetailsDao() - .updateAnsweredQuestions(countyCode, pollingStationNumber, formId) - }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) - .subscribe() + .subscribeOn(Schedulers.io()).flatMap( + { + Log.d(TAG, "Syncing answers: ${it.size} answers to sync") + syncAnswers(it) + }, + { answersList, response -> Pair(answersList, response) } + ).observeOn(AndroidSchedulers.mainThread()).subscribe({ + if (it.first.isNotEmpty()) { + Observable.fromCallable { + db.formDetailsDao() + .updateAnsweredQuestions(countyCode, pollingStationNumber, formId) + }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribe() + } }, { Log.i(TAG, it.message ?: "Error on synchronizing data") }) @@ -282,6 +294,18 @@ class Repository : KoinComponent { } } + fun getNotesAsObservable( + countyCode: String, + pollingStationNumber: Int, + selectedQuestion: Question? + ): Observable> { + return if (selectedQuestion == null) { + db.noteDao().getNotesAsObservable(countyCode, pollingStationNumber) + } else { + db.noteDao().getNotesForQuestionAsObservable(countyCode, pollingStationNumber, selectedQuestion.id) + } + } + fun getNotSyncedNotes(): LiveData = db.noteDao().getCountOfNotSyncedNotes() @SuppressLint("CheckResult") @@ -295,35 +319,54 @@ class Repository : KoinComponent { } fun saveNote(note: Note): Observable = - Single.fromCallable { db.noteDao().save(note) }.toObservable().flatMap { - note.id = it[0].toInt() + Single.fromCallable { + db.noteDao().save(note).first() + }.flatMapObservable { + note.id = it.toInt() postNote(note) } private fun postNote(note: Note): Observable { - var body: MultipartBody.Part? = null - var questionId = 0 - note.uriPath?.let { - val file = File(it) - val requestFile = file.asRequestBody("multipart/form-data".toMediaTypeOrNull()) - body = MultipartBody.Part.createFormData("file", file.name, requestFile) - - } - note.questionId?.let { - questionId = 0 - } + val noteFiles = note.uriPath?.let { + if (it.isEmpty()) return@let null + val filePaths = it.split(Constants.FILES_PATHS_SEPARATOR) + if (filePaths.isEmpty()) null else filePaths.map { path -> File(path) } + }?.filter { it.exists() } + Log.d(TAG, "Files to be uploaded with note: ${noteFiles?.map { it.absolutePath }}") + val body: Array? = noteFiles?.let { paths -> + mutableListOf().apply { + paths.forEach { file -> + this.add( + MultipartBody.Part.createFormData( + "files", + file.name, + file.asRequestBody("multipart/form-data".toMediaTypeOrNull()) + ) + ) + } + } + }?.toTypedArray() + val questionId = note.questionId ?: 0 return apiInterface.postNote( - body, note.countyCode.createMultipart("CountyCode"), + body, + note.countyCode.createMultipart("CountyCode"), note.pollingStationNumber.toString().createMultipart("PollingStationNumber"), questionId.toString().createMultipart("QuestionId"), note.description.createMultipart("Text") ).doOnNext { note.synced = true + note.uriPath = combineApiFilesUrls(it) db.noteDao().updateNote(note) + noteFiles?.forEach { uploadedFile -> uploadedFile.delete() } } } + private fun combineApiFilesUrls(response: ResponseBody): String? = kotlin.runCatching { + val parsedResponse = Gson().fromJson(response.charStream(), postTypeToken) + parsedResponse.filesAddress.joinToString(separator = Constants.FILES_PATHS_SEPARATOR) + }.getOrNull() + @SuppressLint("CheckResult") fun syncData() { if (!syncInProgress) { @@ -384,5 +427,20 @@ class Repository : KoinComponent { .flatMap { postPollingStationDetails(it) } .subscribeOn(Schedulers.io()) } + + fun clearDBData() { + AsyncTask.execute { + db.noteDao().deleteAll() + + db.formDetailsDao().deleteAllAnswers() + db.formDetailsDao().deleteAllQuestions() + db.formDetailsDao().deleteAllSections() + db.formDetailsDao().deleteAllForms() + + db.pollingStationDao().deleteAll() + } + } + + fun getVisitedStations() = db.pollingStationDao().getVisitedPollingStations() } diff --git a/app/src/main/java/ro/code4/monitorizarevot/services/ApiInterface.kt b/app/src/main/java/ro/code4/monitorizarevot/services/ApiInterface.kt index 21d36782..5d917d3a 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/services/ApiInterface.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/services/ApiInterface.kt @@ -28,13 +28,12 @@ interface ApiInterface { fun postQuestionAnswer(@Body responseAnswer: ResponseAnswerContainer): Observable @Multipart - @POST("/api/v2/note/upload") + @POST("/api/v2/note") fun postNote( - @Part file: MultipartBody.Part?, + @Part files: Array?, @Part countyCode: MultipartBody.Part, @Part pollingStationNumber: MultipartBody.Part, @Part questionId: MultipartBody.Part, @Part description: MultipartBody.Part ): Observable - } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/services/LoginInterface.kt b/app/src/main/java/ro/code4/monitorizarevot/services/LoginInterface.kt index 837e6d9c..785b8c2b 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/services/LoginInterface.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/services/LoginInterface.kt @@ -9,9 +9,6 @@ import ro.code4.monitorizarevot.data.model.User import ro.code4.monitorizarevot.data.model.response.LoginResponse interface LoginInterface { - @POST("access/authorize") + @POST("/api/v2/access/authorize") fun login(@Body user: User): Observable - - @POST("notification/register") - fun registerForNotification(@Query("Token") token: String, @Query("ChannelName") channelName: String = "Firebase"): Observable } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseActivity.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseActivity.kt index 8163c8fe..1ff0b752 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseActivity.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.text.SpannableString import android.text.method.LinkMovementMethod import android.text.util.Linkify +import android.view.MotionEvent import android.view.View import android.widget.TextView import android.widget.Toast @@ -13,12 +14,12 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Observer import com.google.android.material.snackbar.Snackbar -import com.google.firebase.messaging.RemoteMessage import com.google.gson.Gson import retrofit2.HttpException import ro.code4.monitorizarevot.R import ro.code4.monitorizarevot.helper.APIError400 import ro.code4.monitorizarevot.helper.LocaleManager +import ro.code4.monitorizarevot.helper.collapseKeyboardIfFocusOutsideEditText import ro.code4.monitorizarevot.helper.fromJson import ro.code4.monitorizarevot.helper.lifecycle.ActivityCallbacks import ro.code4.monitorizarevot.interfaces.Layout @@ -131,4 +132,14 @@ abstract class BaseActivity : AppCompatActivity(), Layout .show() } + // Collapse the keyboard when the user taps outside the EditText + override fun dispatchTouchEvent(motionEvent: MotionEvent): Boolean { + + currentFocus?.let { oldFocus -> + super.dispatchTouchEvent(motionEvent) + val newFocus = currentFocus ?: oldFocus + collapseKeyboardIfFocusOutsideEditText(motionEvent, oldFocus, newFocus) + } + return super.dispatchTouchEvent(motionEvent) + } } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseAnalyticsFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseAnalyticsFragment.kt index f429c446..c8c52ec6 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseAnalyticsFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/base/BaseAnalyticsFragment.kt @@ -27,7 +27,7 @@ abstract class BaseAnalyticsFragment : Fragment(), AnalyticsScreenName { override fun onResume() { super.onResume() - firebaseAnalytics.setCurrentScreen(activity!!, getString(screenName), null) + firebaseAnalytics.setCurrentScreen(requireActivity(), getString(screenName), null) } fun logAnalyticsEvent(event: Event, vararg params: Param) { diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsFragment.kt index 26d92b1f..001a7331 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsFragment.kt @@ -10,6 +10,7 @@ import org.koin.android.viewmodel.ext.android.viewModel import org.parceler.Parcels import ro.code4.monitorizarevot.R import ro.code4.monitorizarevot.helper.Constants.FORM +import ro.code4.monitorizarevot.helper.Constants.NOTE import ro.code4.monitorizarevot.helper.Constants.QUESTION import ro.code4.monitorizarevot.helper.changePollingStation import ro.code4.monitorizarevot.helper.replaceFragment @@ -17,6 +18,7 @@ import ro.code4.monitorizarevot.ui.base.ViewModelFragment import ro.code4.monitorizarevot.ui.forms.questions.QuestionsDetailsFragment import ro.code4.monitorizarevot.ui.forms.questions.QuestionsListFragment import ro.code4.monitorizarevot.ui.main.MainActivity +import ro.code4.monitorizarevot.ui.notes.NoteDetailsFragment import ro.code4.monitorizarevot.ui.notes.NoteFragment class FormsFragment : ViewModelFragment() { @@ -37,16 +39,16 @@ class FormsFragment : ViewModelFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.pollingStation().observe(this, Observer { + viewModel.pollingStation().observe(viewLifecycleOwner, Observer { pollingStationBarText.text = getString(R.string.polling_station, it.pollingStationNumber, it.countyName) }) - viewModel.title().observe(this, Observer { + viewModel.title().observe(viewLifecycleOwner, Observer { (activity as MainActivity).setTitle(it) }) - viewModel.selectedForm().observe(this, Observer { + viewModel.selectedForm().observe(viewLifecycleOwner, Observer { childFragmentManager.replaceFragment( R.id.content, QuestionsListFragment(), @@ -54,7 +56,7 @@ class FormsFragment : ViewModelFragment() { QuestionsListFragment.TAG ) }) - viewModel.selectedQuestion().observe(this, Observer { + viewModel.selectedQuestion().observe(viewLifecycleOwner, Observer { childFragmentManager.replaceFragment( R.id.content, QuestionsDetailsFragment(), @@ -65,17 +67,24 @@ class FormsFragment : ViewModelFragment() { QuestionsDetailsFragment.TAG ) }) - viewModel.navigateToNotes().observe(this, Observer { + viewModel.navigateToNotes().observe(viewLifecycleOwner, Observer { childFragmentManager.replaceFragment( R.id.content, NoteFragment(), - bundleOf( - Pair(QUESTION, Parcels.wrap(it)) - ), + it?.let { bundleOf(Pair(QUESTION, Parcels.wrap(it))) }, NoteFragment.TAG ) }) + viewModel.selectedNote().observe(viewLifecycleOwner, Observer { + childFragmentManager.replaceFragment( + R.id.content, + NoteDetailsFragment(), + bundleOf(Pair(NOTE, Parcels.wrap(it))), + NoteDetailsFragment.TAG + ) + }) + pollingStationBarButton.setOnClickListener { viewModel.notifyChangeRequested() (activity as AppCompatActivity).changePollingStation() diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsListFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsListFragment.kt index c4a0efb9..767ca5d3 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsListFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsListFragment.kt @@ -41,28 +41,32 @@ class FormsListFragment : ViewModelFragment() { override fun onAttach(context: Context) { super.onAttach(context) - viewModel = getSharedViewModel(from = { parentFragment!! }) + viewModel = getSharedViewModel(from = { requireParentFragment() }) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.forms().observe(this, Observer { + viewModel.forms().observe(viewLifecycleOwner, Observer { formAdapter.items = it + updateSyncSuccessfulNotice() }) - viewModel.syncVisibility().observe(this, Observer { - syncGroup.visibility = it + viewModel.unSyncedDataCount().observe(viewLifecycleOwner, Observer { + syncGroup.visibility = if (it > 0) View.VISIBLE else View.GONE + updateSyncSuccessfulNotice() }) viewModel.setTitle(getString(R.string.title_forms_list)) syncButton.setOnClickListener { - // TODO send number of unsynced items - logAnalyticsEvent(Event.MANUAL_SYNC, Param(ParamKey.NUMBER_NOT_SYNCED, 0)) + val unSyncedCount = viewModel.unSyncedDataCount().value ?: 0 + logAnalyticsEvent(Event.MANUAL_SYNC, Param(ParamKey.NUMBER_NOT_SYNCED, unSyncedCount)) if (!mContext.isOnline()) { - Snackbar.make(syncButton, getString(R.string.form_sync_no_internet), Snackbar.LENGTH_SHORT) - .show() - + Snackbar.make( + syncButton, + getString(R.string.form_sync_no_internet), + Snackbar.LENGTH_SHORT + ).show() return@setOnClickListener } @@ -78,4 +82,22 @@ class FormsListFragment : ViewModelFragment() { ) } } + + /** + * Update the visibility of a successful sync indicator based on the values of the LiveDatas for forms + * and the sync Button. If we only use the syncVisibility() LiveData then we could get into a situation + * when the syncVisibility LiveData will trigger before the forms LiveData and we will have an empty + * screen which shows that everything is synchronized(and the info views will also jump around after the + * forms will be loaded). + */ + private fun updateSyncSuccessfulNotice() { + val areFormsVisible = viewModel.forms().value?.let { true } ?: false + viewModel.unSyncedDataCount().value?.let { unSyncedCount -> + when (if (unSyncedCount > 0) View.VISIBLE else View.GONE) { + View.VISIBLE -> syncSuccessGroup.visibility = View.GONE + View.GONE -> syncSuccessGroup.visibility = + if (areFormsVisible) View.VISIBLE else View.GONE + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsViewModel.kt index e3c6d8f4..231b6650 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/FormsViewModel.kt @@ -1,31 +1,35 @@ package ro.code4.monitorizarevot.ui.forms import android.annotation.SuppressLint -import android.view.View import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import com.google.firebase.remoteconfig.FirebaseRemoteConfig +import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.functions.BiFunction import io.reactivex.schedulers.Schedulers import ro.code4.monitorizarevot.adapters.helper.AddNoteListItem import ro.code4.monitorizarevot.adapters.helper.FormListItem import ro.code4.monitorizarevot.adapters.helper.ListItem import ro.code4.monitorizarevot.data.model.FormDetails +import ro.code4.monitorizarevot.data.model.Note import ro.code4.monitorizarevot.data.model.Question import ro.code4.monitorizarevot.data.pojo.AnsweredQuestionPOJO import ro.code4.monitorizarevot.data.pojo.FormWithSections import ro.code4.monitorizarevot.data.pojo.PollingStationInfo import ro.code4.monitorizarevot.helper.Constants.REMOTE_CONFIG_FILTER_DIASPORA_FORMS import ro.code4.monitorizarevot.helper.completedPollingStationConfig -import ro.code4.monitorizarevot.helper.zipLiveData import ro.code4.monitorizarevot.ui.base.BaseFormViewModel +import ro.code4.monitorizarevot.ui.notes.NoteFormQuestionCodes class FormsViewModel : BaseFormViewModel() { private val formsLiveData = MutableLiveData>() private val selectedFormLiveData = MutableLiveData() private val selectedQuestionLiveData = MutableLiveData>() + private val selectedNoteLiveData = MutableLiveData() private val syncVisibilityLiveData = MediatorLiveData() + private val unSyncedDataCountLiveData = MediatorLiveData() private val navigateToNotesLiveData = MutableLiveData() private val pollingStationLiveData = MutableLiveData() @@ -35,38 +39,34 @@ class FormsViewModel : BaseFormViewModel() { } private fun subscribe() { - val notSyncedQuestionsCount = repository.getNotSyncedQuestions() val notSyncedNotesCount = repository.getNotSyncedNotes() val notSyncedPollingStationsCount = repository.getNotSyncedPollingStationsCount() fun update() { - syncVisibilityLiveData.value = - if ((notSyncedQuestionsCount.value ?: 0) + (notSyncedNotesCount.value - ?: 0) + (notSyncedPollingStationsCount.value ?: 0) > 0 - ) View.VISIBLE else View.GONE - } - syncVisibilityLiveData.addSource(notSyncedQuestionsCount) { - update() - } - syncVisibilityLiveData.addSource(notSyncedNotesCount) { - update() - } - syncVisibilityLiveData.addSource(notSyncedPollingStationsCount) { - update() + unSyncedDataCountLiveData.value = + (notSyncedQuestionsCount.value ?: 0) + (notSyncedNotesCount.value ?: 0) + + (notSyncedPollingStationsCount.value ?: 0) } + unSyncedDataCountLiveData.addSource(notSyncedQuestionsCount) { update() } + unSyncedDataCountLiveData.addSource(notSyncedNotesCount) { update() } + unSyncedDataCountLiveData.addSource(notSyncedPollingStationsCount) { update() } - zipLiveData( + disposables.add(Observable.combineLatest( repository.getAnswers(countyCode, pollingStationNumber), - repository.getFormsWithQuestions() - ).observeForever { + repository.getFormsWithQuestions(), + BiFunction, List, Pair, List>> { t1, t2 -> + Pair(t1, t2) + } + ).subscribe { processList(it.first, it.second) - } + }) } fun forms(): LiveData> = formsLiveData fun selectedForm(): LiveData = selectedFormLiveData fun selectedQuestion(): LiveData> = selectedQuestionLiveData + fun selectedNote() : LiveData = selectedNoteLiveData fun navigateToNotes(): LiveData = navigateToNotesLiveData fun pollingStation(): LiveData = pollingStationLiveData @@ -112,13 +112,13 @@ class FormsViewModel : BaseFormViewModel() { } catch (e: Exception) { false } - val formsList = when { + var formsList = when { !filterDiasporaForms || pollingStationLiveData.value?.isDiaspora == true -> forms.map { FormListItem(it) } else -> forms.filter { it.form.diaspora == false }.map { FormListItem(it) } } - formsList.sortedBy { it.formWithSections.form.order } + formsList = formsList.sortedBy { it.formWithSections.form.order } items.addAll(formsList) items.add(AddNoteListItem()) formsLiveData.postValue(items) @@ -132,7 +132,13 @@ class FormsViewModel : BaseFormViewModel() { selectedQuestionLiveData.postValue(Pair(selectedFormLiveData.value!!, question)) } + fun selectNote(note: Note) { + selectedNoteLiveData.postValue(note) + } + fun syncVisibility(): LiveData = syncVisibilityLiveData + + fun unSyncedDataCount(): LiveData = unSyncedDataCountLiveData fun sync() { repository.syncData() diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/BaseQuestionViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/BaseQuestionViewModel.kt index 7a267880..11bbb8ac 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/BaseQuestionViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/BaseQuestionViewModel.kt @@ -2,38 +2,73 @@ package ro.code4.monitorizarevot.ui.forms.questions import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.functions.BiFunction +import io.reactivex.functions.Function3 +import io.reactivex.schedulers.Schedulers import ro.code4.monitorizarevot.adapters.helper.ListItem import ro.code4.monitorizarevot.data.model.FormDetails +import ro.code4.monitorizarevot.data.model.Note import ro.code4.monitorizarevot.data.pojo.AnsweredQuestionPOJO import ro.code4.monitorizarevot.data.pojo.SectionWithQuestions -import ro.code4.monitorizarevot.helper.zipLiveData import ro.code4.monitorizarevot.ui.base.BaseFormViewModel abstract class BaseQuestionViewModel : BaseFormViewModel() { - val questionsLiveData = MutableLiveData>() - var selectedFormId: Int = -1 - fun questions(): LiveData> = questionsLiveData private fun getQuestions(formId: Int) { - selectedFormId = formId - zipLiveData( - repository.getSectionsWithQuestions(formId), - repository.getAnswersForForm(countyCode, pollingStationNumber, formId) - ).observeForever { - processList(it.first, it.second) + val noteSource = provideNoteSource() + val baseSource = if (noteSource != null) { + Observable.combineLatest( + repository.getSectionsWithQuestions(formId), + repository.getAnswersForForm(countyCode, pollingStationNumber, formId), + noteSource, + Function3 { t1, t2, t3 -> + t1.forEach { + it.questions.forEach { q -> + q.question.hasNotes = t3.any { n -> n.questionId == q.question.id } + } + } + Pair(t1, t2) + } + ) + } else { + Observable.combineLatest(repository.getSectionsWithQuestions(formId), + repository.getAnswersForForm(countyCode, pollingStationNumber, formId), + BiFunction, List, Pair, List>> { t1, t2 -> + Pair(t1, t2) + }) } - + disposables.add(baseSource.subscribeOn(Schedulers.computation()) + .map { dataPair -> + // sort on orderNumber the sections along with their questions and answers + val sortedSections = dataPair.first.sortedBy { it.section.orderNumber } + for (sortedSection in sortedSections) { + val sortedQuestions = + sortedSection.questions.sortedBy { it.question.orderNumber } + for (sortedQuestion in sortedQuestions) { + sortedQuestion.answers = sortedQuestion.answers?.sortedBy { it.orderNumber } + } + sortedSection.questions = sortedQuestions + } + Pair(sortedSections, dataPair.second) + }.observeOn(AndroidSchedulers.mainThread()) + .subscribe { result -> processList(result.first, result.second) }) } + // TODO this method should also run on a background thread to avoid doing lengthy + // operations(like sorting or iterating over large collections) on the main thread abstract fun processList( sections: List, answersForForm: List ) + open fun provideNoteSource(): Observable>? = null + fun setData(formDetails: FormDetails) { getQuestions(formDetails.id) setTitle(formDetails.description) diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsViewModel.kt index a3b226c1..0215d3a3 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsDetailsViewModel.kt @@ -1,9 +1,11 @@ package ro.code4.monitorizarevot.ui.forms.questions +import io.reactivex.Observable import ro.code4.monitorizarevot.adapters.helper.ListItem import ro.code4.monitorizarevot.adapters.helper.MultiChoiceListItem import ro.code4.monitorizarevot.adapters.helper.QuestionDetailsListItem import ro.code4.monitorizarevot.adapters.helper.SingleChoiceListItem +import ro.code4.monitorizarevot.data.model.Note import ro.code4.monitorizarevot.data.model.answers.AnsweredQuestion import ro.code4.monitorizarevot.data.model.answers.SelectedAnswer import ro.code4.monitorizarevot.data.pojo.AnsweredQuestionPOJO @@ -86,4 +88,8 @@ class QuestionsDetailsViewModel : BaseQuestionViewModel() { repository.syncAnswers(countyCode, pollingStationNumber, selectedFormId) } + override fun provideNoteSource(): Observable> { + return repository.getNotesAsObservable(countyCode, pollingStationNumber, null) + .onErrorReturnItem(emptyList()) + } } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsListFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsListFragment.kt index 2d0ca824..9c4d7b3f 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsListFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsListFragment.kt @@ -37,16 +37,16 @@ class QuestionsListFragment : ViewModelFragment() { override fun onAttach(context: Context) { super.onAttach(context) - baseViewModel = getSharedViewModel(from = { parentFragment!! }) + baseViewModel = getSharedViewModel(from = { requireParentFragment() }) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.questions().observe(this, Observer { + viewModel.questions().observe(viewLifecycleOwner, Observer { questionAdapter.items = it }) - viewModel.title().observe(this, Observer { + viewModel.title().observe(viewLifecycleOwner, Observer { baseViewModel.setTitle(it) }) viewModel.setData(Parcels.unwrap(arguments?.getParcelable((FORM)))) diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsViewModel.kt index 4e4ac2bf..79a9d9f1 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/forms/questions/QuestionsViewModel.kt @@ -1,9 +1,11 @@ package ro.code4.monitorizarevot.ui.forms.questions +import io.reactivex.Observable import ro.code4.monitorizarevot.R import ro.code4.monitorizarevot.adapters.helper.ListItem import ro.code4.monitorizarevot.adapters.helper.QuestionListItem import ro.code4.monitorizarevot.adapters.helper.SectionListItem +import ro.code4.monitorizarevot.data.model.Note import ro.code4.monitorizarevot.data.pojo.AnsweredQuestionPOJO import ro.code4.monitorizarevot.data.pojo.SectionWithQuestions @@ -34,4 +36,8 @@ class QuestionsViewModel : BaseQuestionViewModel() { questionsLiveData.postValue(list) } + override fun provideNoteSource(): Observable> { + return repository.getNotesAsObservable(countyCode, pollingStationNumber, null) + .onErrorReturnItem(emptyList()) + } } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/guide/GuideFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/guide/GuideFragment.kt index 4eca4818..15ed2421 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/guide/GuideFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/guide/GuideFragment.kt @@ -1,20 +1,21 @@ package ro.code4.monitorizarevot.ui.guide +import android.annotation.SuppressLint +import android.graphics.Bitmap import android.os.Bundle import android.view.View import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient import androidx.lifecycle.Observer import kotlinx.android.synthetic.main.fragment_guide.* import org.koin.android.ext.android.inject import ro.code4.monitorizarevot.R -import ro.code4.monitorizarevot.helper.WebClient -import ro.code4.monitorizarevot.ui.base.BaseAnalyticsFragment import ro.code4.monitorizarevot.ui.base.ViewModelFragment import ro.code4.monitorizarevot.widget.ProgressDialogFragment -class GuideFragment : ViewModelFragment(), WebClient.WebLoaderListener { - - +class GuideFragment : ViewModelFragment() { override val layout: Int get() = R.layout.fragment_guide override val screenName: Int @@ -26,26 +27,42 @@ class GuideFragment : ViewModelFragment(), WebClient.WebLoaderLi } override val viewModel: GuideViewModel by inject() + @SuppressLint("SetJavaScriptEnabled") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - webView.settings.setSupportZoom(true) - webView.settings.javaScriptEnabled = true - webView.webChromeClient = WebChromeClient() - webView.webViewClient = WebClient(this) - viewModel.url().observe(this, Observer { - webView.loadUrl(it) - }) - } + webView.apply { + settings.setSupportZoom(true) + settings.javaScriptEnabled = true + webChromeClient = WebChromeClient() + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + if (!progressDialog.isResumed) { + progressDialog.showNow(childFragmentManager, ProgressDialogFragment.TAG) + } + } - override fun onPageFinished() { - progressDialog.dismiss() - } + override fun onPageFinished(view: WebView, url: String) { + super.onPageFinished(view, url) + if (view.title.isNullOrEmpty()) { + view.reload() + return + } + progressDialog.dismiss() + } - override fun onLoading() { - if (!progressDialog.isResumed) { - progressDialog.showNow(childFragmentManager, ProgressDialogFragment.TAG) + override fun shouldOverrideUrlLoading( + view: WebView, + request: WebResourceRequest + ): Boolean { + return request.url?.let { !it.path.equals(viewModel.url().value) } ?: true + } + } } + viewModel.url().observe(viewLifecycleOwner, Observer { + webView.loadUrl(it) + }) } override fun onDestroyView() { diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/guide/GuideViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/guide/GuideViewModel.kt index 09ad6824..5fd472cf 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/guide/GuideViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/guide/GuideViewModel.kt @@ -2,17 +2,27 @@ package ro.code4.monitorizarevot.ui.guide import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import com.google.firebase.remoteconfig.FirebaseRemoteConfig import ro.code4.monitorizarevot.BuildConfig +import ro.code4.monitorizarevot.helper.Constants +import ro.code4.monitorizarevot.helper.getStringOrDefault import ro.code4.monitorizarevot.ui.base.BaseViewModel import java.net.URLEncoder class GuideViewModel : BaseViewModel() { - private val urlLiveData = MutableLiveData().apply { - value = "https://docs.google.com/gview?embedded=true&url=${URLEncoder.encode( - BuildConfig.GUIDE_URL, + private val remoteConfig = runCatching { FirebaseRemoteConfig.getInstance() }.getOrNull() + private val guideUrl by lazy { + URLEncoder.encode( + remoteConfig.getStringOrDefault( + Constants.REMOTE_CONFIG_OBSERVER_GUIDE_URL, + BuildConfig.GUIDE_URL + ), "UTF-8" - )}" + ) } + private val urlLiveData = MutableLiveData( + "https://docs.google.com/gview?embedded=true&url=$guideUrl" + ) fun url(): LiveData = urlLiveData } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginViewModel.kt index c83c1bd3..e1868d3c 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/login/LoginViewModel.kt @@ -1,13 +1,11 @@ package ro.code4.monitorizarevot.ui.login import android.content.SharedPreferences -import android.util.Log import androidx.lifecycle.LiveData import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.iid.FirebaseInstanceId import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers -import org.koin.android.ext.android.inject import org.koin.core.inject import ro.code4.monitorizarevot.BuildConfig import ro.code4.monitorizarevot.analytics.Event @@ -34,19 +32,16 @@ class LoginViewModel : BaseViewModel() { getFirebaseToken(phone, password) } - private fun onSuccessfulLogin(loginResponse: LoginResponse, firebaseToken: String) { + private fun onSuccessfulLogin(loginResponse: LoginResponse) { logD("onSuccessfulLogin") sharedPreferences.saveToken(loginResponse.accessToken) - registerForNotification(firebaseToken) - } - private fun onSuccessfulRegisteredForNotification() { logD("onSuccessfulRegisteredForNotification") - if (sharedPreferences.hasCompletedOnboarding()) { - loginLiveData.postValue(Result.Success(PollingStationActivity::class.java)) - } else { - loginLiveData.postValue(Result.Success(OnboardingActivity::class.java)) + val nextActivity = when (sharedPreferences.hasCompletedOnboarding()) { + true -> PollingStationActivity::class.java + false -> OnboardingActivity::class.java } + loginLiveData.postValue(Result.Success(nextActivity)) } private fun getFirebaseToken(phone: String, password: String) { @@ -73,35 +68,17 @@ class LoginViewModel : BaseViewModel() { fun login(phone: String, password: String, firebaseToken: String) { logD("login: $phone : $password -> $firebaseToken") - disposables.add( - loginRepository.login(User(phone, password, firebaseToken)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ loginResponse -> - logD("Login successful! Token received!") - onSuccessfulLogin(loginResponse, firebaseToken) - }, { throwable -> + loginRepository.login(User(phone, password, firebaseToken)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { loginResponse -> onSuccessfulLogin(loginResponse) }, + { throwable -> firebaseAnalytics.logEvent(Event.LOGIN_FAILED.title, null) logE("Login failed!", throwable) onError(throwable) }) - ) - } - - private fun registerForNotification(firebaseToken: String) { - logD("registerForNotification with $firebaseToken") - disposables.add( - loginRepository.registerForNotification(firebaseToken) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ - logD("Registered for notifications on firebase!") - onSuccessfulRegisteredForNotification() - }, { throwable -> - logE("Register for notification failed!", throwable) - onError(throwable) - }) - ) + .run { disposables.add(this) } } override fun onError(throwable: Throwable) { diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/main/MainActivity.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/main/MainActivity.kt index c21f6ae2..ec9195ae 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/main/MainActivity.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/main/MainActivity.kt @@ -1,6 +1,11 @@ package ro.code4.monitorizarevot.ui.main +import android.graphics.Typeface.BOLD +import android.graphics.Typeface.NORMAL import android.os.Bundle +import android.text.SpannableString +import android.text.style.StyleSpan +import android.view.MenuItem import androidx.core.view.GravityCompat import androidx.lifecycle.Observer import androidx.navigation.NavController @@ -15,14 +20,12 @@ import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.app_bar_main.* import org.koin.android.ext.android.inject import org.koin.android.viewmodel.ext.android.viewModel -import ro.code4.monitorizarevot.BuildConfig import ro.code4.monitorizarevot.R import ro.code4.monitorizarevot.analytics.Event import ro.code4.monitorizarevot.helper.* import ro.code4.monitorizarevot.ui.base.BaseActivity import ro.code4.monitorizarevot.ui.login.LoginActivity - class MainActivity : BaseActivity() { override val layout: Int get() = R.layout.activity_main @@ -31,7 +34,7 @@ class MainActivity : BaseActivity() { override val viewModel: MainViewModel by viewModel() private lateinit var navController: NavController private lateinit var appBarConfiguration: AppBarConfiguration - + private var selectedItem: MenuItem? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setSupportActionBar(toolbar) @@ -55,10 +58,22 @@ class MainActivity : BaseActivity() { supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_menu) navView.setCheckedItem(R.id.nav_forms) + navView.menu.findItem(R.id.nav_safety).isVisible = viewModel.isSafetyItemVisible + navView.menu.findItem(R.id.nav_obs_feedback).isVisible = viewModel.isObserverFeedbackItemVisible + + selectedItem = navView.checkedItem + selectedItem!!.title = getStyledSpannableString(selectedItem!!.title.toString(), BOLD) + // Workaround to allow actions and navigation in the same component + navView.setNavigationItemSelectedListener { item -> val handled = onNavDestinationSelected(item, navController) + if (handled) { + selectedItem!!.title = getStyledSpannableString(selectedItem!!.title.toString(), NORMAL) + item.title = getStyledSpannableString(item.title.toString(), BOLD) + selectedItem = item + drawerLayout.closeDrawer(navView) true } else { @@ -75,21 +90,32 @@ class MainActivity : BaseActivity() { true } R.id.nav_safety -> { - val result = applicationContext.browse(BuildConfig.SAFETY_URL, true) + val result = browse(viewModel.safetyUrl, true) + if (!result) { + logW("No app to view ${viewModel.safetyUrl}") + } + true + } + R.id.nav_obs_feedback -> { + val result = browse(viewModel.observerFeedbackUrl, true) if (!result) { - logW("No app to view " + BuildConfig.SAFETY_URL) + logW("No app to view ${viewModel.observerFeedbackUrl}") } true } + R.id.nav_visited_stations -> { + showVisitedPollingStations() + true + } + R.id.nav_logout -> { + viewModel.logout() + true + } else -> false } } } - navLogout.setOnClickListener { - viewModel.logout() - } - viewModel.onLogoutLiveData().observe(this, Observer { startActivityWithoutTrace(LoginActivity::class.java) }) @@ -110,4 +136,11 @@ class MainActivity : BaseActivity() { super.onBackPressed() } } + + private fun getStyledSpannableString(title: String, style: Int): SpannableString { + val spanString = SpannableString(title) + spanString.setSpan(StyleSpan(style), 0, spanString.length, 0) + return spanString + } } + diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/main/MainViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/main/MainViewModel.kt index b34057d3..e856f1af 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/main/MainViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/main/MainViewModel.kt @@ -1,14 +1,33 @@ package ro.code4.monitorizarevot.ui.main import android.content.SharedPreferences +import com.google.firebase.remoteconfig.FirebaseRemoteConfig import org.koin.core.inject +import ro.code4.monitorizarevot.BuildConfig +import ro.code4.monitorizarevot.helper.Constants import ro.code4.monitorizarevot.helper.SingleLiveEvent import ro.code4.monitorizarevot.helper.completedPollingStationConfig import ro.code4.monitorizarevot.helper.deleteToken +import ro.code4.monitorizarevot.helper.getStringOrDefault import ro.code4.monitorizarevot.ui.base.BaseViewModel class MainViewModel : BaseViewModel() { private val sharedPreferences: SharedPreferences by inject() + private val remoteConfig = runCatching { FirebaseRemoteConfig.getInstance() }.getOrNull() + internal val safetyUrl by lazy { + remoteConfig.getStringOrDefault( + Constants.REMOTE_CONFIG_SAFETY_GUIDE_URL, + BuildConfig.SAFETY_URL + ) + } + internal val observerFeedbackUrl by lazy { + remoteConfig.getStringOrDefault( + Constants.REMOTE_CONFIG_OBSERVER_FEEDBACK_URL, + BuildConfig.OBSERVER_FEEDBACK_URL + ) + } + internal val isSafetyItemVisible = safetyUrl.isNotBlank() + internal val isObserverFeedbackItemVisible = observerFeedbackUrl.isNotBlank() private val onLogoutLiveData = SingleLiveEvent() fun onLogoutLiveData(): SingleLiveEvent = onLogoutLiveData diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteDetailsFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteDetailsFragment.kt new file mode 100644 index 00000000..e571aa0c --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteDetailsFragment.kt @@ -0,0 +1,73 @@ +package ro.code4.monitorizarevot.ui.notes + +import android.content.Context +import android.graphics.Color +import android.os.Bundle +import android.view.View +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar +import com.yqritc.recyclerviewflexibledivider.HorizontalDividerItemDecoration +import kotlinx.android.synthetic.main.fragment_note_detail.* +import org.koin.android.viewmodel.ext.android.getSharedViewModel +import org.koin.android.viewmodel.ext.android.getViewModel +import org.parceler.Parcels +import ro.code4.monitorizarevot.R +import ro.code4.monitorizarevot.adapters.NoteDetailsAdapter +import ro.code4.monitorizarevot.data.model.Note +import ro.code4.monitorizarevot.helper.Constants +import ro.code4.monitorizarevot.helper.isOnline +import ro.code4.monitorizarevot.ui.base.ViewModelFragment +import ro.code4.monitorizarevot.ui.forms.FormsViewModel + +class NoteDetailsFragment : ViewModelFragment() { + override val screenName: Int + get() = R.string.title_note + override val layout: Int + get() = R.layout.fragment_note_detail + override lateinit var viewModel: NoteDetailsViewModel + private lateinit var baseViewModel: FormsViewModel + private lateinit var noteContentAdapter: NoteDetailsAdapter + + override fun onAttach(context: Context) { + super.onAttach(context) + viewModel = getViewModel() + baseViewModel = getSharedViewModel(from = { requireParentFragment() }) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.setData(Parcels.unwrap(arguments?.getParcelable((Constants.NOTE)))) + noteContentAdapter = NoteDetailsAdapter(requireContext()) + noteContent.apply { + layoutManager = LinearLayoutManager(requireContext()) + adapter = noteContentAdapter + addItemDecoration( + HorizontalDividerItemDecoration.Builder(requireContext()) + .color(Color.TRANSPARENT) + .sizeResId(R.dimen.small_margin).build() + ) + } + + viewModel.noteDetails.observe(viewLifecycleOwner, Observer { + if (it.isSynced) { + if (requireActivity().isOnline()) { + noteContentAdapter.updateAdapter(it) + } else { + noteContentAdapter.updateAdapter(it.copy(attachedFiles = emptyList())) + Snackbar.make( + view, + getString(R.string.note_details_missing_internet), + Snackbar.LENGTH_LONG + ).show() + } + } else { + noteContentAdapter.updateAdapter(it) + } + }) + } + + companion object { + val TAG = NoteDetailsFragment::class.java.simpleName + } +} diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteDetailsViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteDetailsViewModel.kt new file mode 100644 index 00000000..6805ff78 --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteDetailsViewModel.kt @@ -0,0 +1,70 @@ +package ro.code4.monitorizarevot.ui.notes + +import android.net.Uri +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import org.parceler.Parcel +import org.parceler.ParcelConstructor +import ro.code4.monitorizarevot.data.model.Note +import ro.code4.monitorizarevot.helper.Constants +import ro.code4.monitorizarevot.helper.formatNoteDateTime +import ro.code4.monitorizarevot.ui.base.BaseViewModel + +class NoteDetailsViewModel : BaseViewModel() { + + private var note: Note? = null + private val _noteDetails = MutableLiveData() + val noteDetails: LiveData = _noteDetails + + fun setData(note: Note?) { + note?.let { + this.note = note + _noteDetails.postValue( + NoteDetails( + it.description, + note.formCode?.let { fc -> + note.questionCode?.let { qc -> NoteFormQuestionCodes(fc, qc) } + }, + it.date.formatNoteDateTime(), + unwrapNoteUrls(it), + it.synced + ) + ) + } + } + + private fun unwrapNoteUrls(note: Note): List { + val paths = note.uriPath?.split(Constants.FILES_PATHS_SEPARATOR) ?: emptyList() + return paths.filter { it.isNotEmpty() }.map { NoteAttachment(Uri.parse(it), isVideo(it)) } + } + + private fun isVideo(path: String): Boolean { + val videosExtensions = listOf("mp4", "m4a", "3gp", "ts", "flac", "amr", "ogg", "wav", "mkv") + val lastPointIndex = path.lastIndexOf(".") + return if (lastPointIndex > 0 && lastPointIndex < path.length - 1) { + val extension = path.substring((lastPointIndex + 1) until path.length) + videosExtensions.contains(extension) + } else { + false + } + } +} + +data class NoteDetails( + val description: String, + val codes: NoteFormQuestionCodes?, + val date: String, + val attachedFiles: List, + val isSynced: Boolean +) + +data class NoteAttachment( + val uri: Uri, + val isVideo: Boolean +) + +@Parcel(Parcel.Serialization.BEAN) +data class NoteFormQuestionCodes @ParcelConstructor constructor( + val formCode: String, + val questionCode: String +) \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteFragment.kt index 3ea9690a..f10a5a53 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteFragment.kt @@ -14,6 +14,9 @@ import android.provider.Settings import android.text.TextWatcher import android.view.MotionEvent import android.view.View +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.view.menu.MenuPopupHelper @@ -28,12 +31,12 @@ import org.koin.android.viewmodel.ext.android.viewModel import org.parceler.Parcels import ro.code4.monitorizarevot.R import ro.code4.monitorizarevot.adapters.NoteDelegationAdapter +import ro.code4.monitorizarevot.data.model.FormDetails import ro.code4.monitorizarevot.data.model.Question import ro.code4.monitorizarevot.helper.* import ro.code4.monitorizarevot.helper.Constants.REQUEST_CODE_GALLERY import ro.code4.monitorizarevot.helper.Constants.REQUEST_CODE_RECORD_VIDEO import ro.code4.monitorizarevot.helper.Constants.REQUEST_CODE_TAKE_PHOTO -import ro.code4.monitorizarevot.ui.base.BaseAnalyticsFragment import ro.code4.monitorizarevot.ui.base.ViewModelFragment import ro.code4.monitorizarevot.ui.forms.FormsViewModel @@ -47,18 +50,23 @@ class NoteFragment : ViewModelFragment(), PermissionManager.Permi companion object { val TAG = NoteFragment::class.java.simpleName } + override val viewModel: NoteViewModel by viewModel() private lateinit var baseViewModel: FormsViewModel - private val noteAdapter: NoteDelegationAdapter by lazy { NoteDelegationAdapter() } + private var fqCodes: NoteFormQuestionCodes? = null + private val noteAdapter: NoteDelegationAdapter by lazy { + NoteDelegationAdapter { note -> baseViewModel.selectNote(note) } + } private lateinit var permissionManager: PermissionManager override fun onAttach(context: Context) { super.onAttach(context) - permissionManager = PermissionManager(activity!!, this) - baseViewModel = getSharedViewModel(from = { parentFragment!! }) + permissionManager = PermissionManager(requireActivity(), this) + baseViewModel = getSharedViewModel(from = { requireParentFragment() }) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val noteFileContainer = view.findViewById(R.id.noteFilesContainer) notesList.layoutManager = LinearLayoutManager(mContext) notesList.adapter = noteAdapter notesList.addItemDecoration( @@ -66,18 +74,38 @@ class NoteFragment : ViewModelFragment(), PermissionManager.Permi .color(Color.TRANSPARENT) .sizeResId(R.dimen.small_margin).build() ) - viewModel.title().observe(this, Observer { + viewModel.title().observe(viewLifecycleOwner, Observer { baseViewModel.setTitle(it) }) - viewModel.setData(Parcels.unwrap(arguments?.getParcelable((Constants.QUESTION)))) - viewModel.notes().observe(this, Observer { + val selectedForm: FormDetails? = baseViewModel.selectedForm().value + val selectedQuestion: Question? = arguments?.let { + Parcels.unwrap(it.getParcelable(Constants.QUESTION)) + } + fqCodes = if (selectedForm != null && selectedQuestion != null) { + NoteFormQuestionCodes(selectedForm.code, selectedQuestion.code) + } else { + null + } + viewModel.setData(selectedQuestion, fqCodes) + + viewModel.notes().observe(viewLifecycleOwner, Observer { noteAdapter.items = it }) - viewModel.fileName().observe(this, Observer { - filenameText.text = it - filenameText.visibility = View.VISIBLE - addMediaButton.visibility = View.GONE + viewModel.filesNames().observe(viewLifecycleOwner, Observer { + noteFileContainer.visibility = View.VISIBLE + noteFileContainer.removeAllViews() + it.forEachIndexed { index, filename -> + val attachmentView = requireActivity().layoutInflater.inflate( + R.layout.include_note_filename, noteFileContainer, false + ).also { view -> + view.findViewById(R.id.filenameText).text = filename + view.findViewById(R.id.deleteFile).setOnClickListener { + viewModel.deleteFile(filename, index) + } + } + noteFileContainer.addView(attachmentView) + } }) viewModel.submitCompleted().observe(this, Observer { activity?.onBackPressed() @@ -122,11 +150,11 @@ class NoteFragment : ViewModelFragment(), PermissionManager.Permi R.id.note_gallery -> openGallery() R.id.note_photo -> { val file = takePicture() - viewModel.addFile(file) + viewModel.addUserGeneratedFile(file) } R.id.note_video -> { val file = takeVideo() - viewModel.addFile(file) + viewModel.addUserGeneratedFile(file) } } true @@ -144,7 +172,7 @@ class NoteFragment : ViewModelFragment(), PermissionManager.Permi viewModel.addMediaToGallery() } REQUEST_CODE_GALLERY -> { - viewModel.getMediaFromGallery(data?.data) + viewModel.addMediaFromGallery(data?.clipData, data?.data) } } } diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteViewModel.kt index 5b90d1eb..cbcc1d56 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/notes/NoteViewModel.kt @@ -2,6 +2,7 @@ package ro.code4.monitorizarevot.ui.notes import android.annotation.SuppressLint import android.app.Application +import android.content.ClipData import android.content.Intent import android.net.Uri import android.util.Log @@ -17,6 +18,7 @@ import ro.code4.monitorizarevot.adapters.helper.NoteListItem import ro.code4.monitorizarevot.adapters.helper.SectionListItem import ro.code4.monitorizarevot.data.model.Note import ro.code4.monitorizarevot.data.model.Question +import ro.code4.monitorizarevot.helper.Constants import ro.code4.monitorizarevot.helper.FileUtils import ro.code4.monitorizarevot.helper.SingleLiveEvent import ro.code4.monitorizarevot.helper.observeOnce @@ -28,9 +30,9 @@ class NoteViewModel : BaseFormViewModel() { private val app: Application by inject() private val notesLiveData = MutableLiveData>() - private val fileNameLiveData = MutableLiveData() + private val filesNamesLiveData = MutableLiveData>() private val submitCompletedLiveData = SingleLiveEvent() - private var noteFile: File? = null + private var noteFiles = mutableListOf() private val listObserver = Observer> { list -> processList(list) @@ -43,11 +45,14 @@ class NoteViewModel : BaseFormViewModel() { } fun notes(): LiveData> = notesLiveData - fun fileName(): LiveData = fileNameLiveData + fun filesNames(): LiveData> = filesNamesLiveData fun submitCompleted(): SingleLiveEvent = submitCompletedLiveData private var selectedQuestion: Question? = null - fun setData(question: Question?) { + private var fqCodes: NoteFormQuestionCodes? = null + + fun setData(question: Question?, codes: NoteFormQuestionCodes?) { selectedQuestion = question + fqCodes = codes repository.getNotes(countyCode, pollingStationNumber, selectedQuestion) .observeOnce(listObserver) } @@ -68,9 +73,15 @@ class NoteViewModel : BaseFormViewModel() { note.pollingStationNumber = pollingStationNumber note.countyCode = countyCode note.description = text - note.uriPath = noteFile?.absolutePath - repository.saveNote(note).subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()).subscribe( + note.uriPath = concatFilePathsOrNull() + fqCodes?.let { + note.formCode = it.formCode + note.questionCode = it.questionCode + } + repository.saveNote(note) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( {}, { Log.d(TAG, it.toString()) @@ -83,29 +94,76 @@ class NoteViewModel : BaseFormViewModel() { } } - fun getMediaFromGallery(uri: Uri?) { - uri?.let { - val filePath = FileUtils.getPath(app, it) - if (filePath != null) { - val file = File(filePath) - fileNameLiveData.postValue(file.name) - noteFile = file - } else { - messageIdToastLiveData.postValue(app.getString(ro.code4.monitorizarevot.R.string.error_permission_external_storage)) + private fun concatFilePathsOrNull(): String? { + val joinedPaths = + noteFiles.joinToString(separator = Constants.FILES_PATHS_SEPARATOR) { it.absolutePath } + return if (joinedPaths.isEmpty()) null else joinedPaths + } + + /** + * Makes copies of the file/files selected by the user so they are available as a note upload. Depending + * on the user selection, only one of the parameters will be initialized. + * + * @param clipData non null if the user selects multiple files + * @param uri non null if the user selects a single file + */ + fun addMediaFromGallery(clipData: ClipData?, uri: Uri?) { + if (clipData != null) { + if (clipData.itemCount == 0) return + // flag which will indicate if there was a problem with processing any of the files, so we can + // show an error to the user at the end + var hasFailedFiles = false + for (cdItemPosition in 0 until clipData.itemCount) { + val fileSaveStatus = runCatching { + FileUtils.copyFileToCache(app, clipData.getItemAt(cdItemPosition).uri) + } + if (fileSaveStatus.exceptionOrNull() != null) { + hasFailedFiles = true + } + fileSaveStatus.getOrNull()?.let { noteFiles.add(it) } + } + filesNamesLiveData.postValue(noteFiles.map { file -> file.name }.toList()) + if (hasFailedFiles) { + messageIdToastLiveData.postValue( + app.getString(R.string.error_note_file_copy_multiple) + ) } + } else if (uri != null) { + runCatching { FileUtils.copyFileToCache(app, uri) }.getOrNull()?.let { + noteFiles.add(it) + filesNamesLiveData.postValue(noteFiles.map { file -> file.name }.toList()) + } ?: messageIdToastLiveData.postValue( + app.getString(R.string.error_note_file_copy_single) + ) } } - fun addFile(file: File?) { - noteFile = file + fun deleteFile(filename: String, position: Int) { + val targetFiles = noteFiles.filter { it.absolutePath.endsWith("/$filename") } + if (targetFiles.isNotEmpty() && targetFiles.size == 1) { + try { + targetFiles[0].delete() + } catch (ex: Exception) { + // ignored + // this try-catch block will prevent the app from crashing if the file can't be deleted(which + // is ok because we dereference the file below and it is safe to remain on disk) + } + } + noteFiles = noteFiles.filterIndexed { index, _ -> index != position }.toMutableList() + filesNamesLiveData.postValue(noteFiles.map { file -> file.name }.toList()) + } + + fun addUserGeneratedFile(file: File?) { + file?.let { noteFiles.add(it) } } fun addMediaToGallery() { - fileNameLiveData.postValue(noteFile?.name) + filesNamesLiveData.postValue(noteFiles.map { file -> file.name }.toList()) val mediaScanIntent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE) - val contentUri = Uri.fromFile(noteFile) - mediaScanIntent.data = contentUri - app.sendBroadcast(mediaScanIntent) + noteFiles.forEach { + val contentUri = Uri.fromFile(it) + mediaScanIntent.data = contentUri + app.sendBroadcast(mediaScanIntent) + } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationActivity.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationActivity.kt index 931af7a3..4e09f2e0 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationActivity.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationActivity.kt @@ -1,6 +1,7 @@ package ro.code4.monitorizarevot.ui.section import android.os.Bundle +import androidx.core.os.bundleOf import androidx.lifecycle.Observer import kotlinx.android.synthetic.main.activity_polling_station.* import org.koin.android.viewmodel.ext.android.viewModel @@ -19,13 +20,20 @@ class PollingStationActivity : BaseActivity() { override val viewModel: PollingStationViewModel by viewModel() + private var countyName: String? = null + private var pollingStationId = -1 + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setSupportActionBar(toolbar) + countyName = intent.getStringExtra(EXTRA_COUNTY_NAME) + pollingStationId = intent.getIntExtra(EXTRA_POLLING_STATION_ID, -1) + viewModel.title().observe(this, Observer { title = it }) viewModel.nextToMain().observe(this, Observer { + viewModel.registerStationSelection() startActivityWithoutTrace(MainActivity::class.java) }) viewModel.next().observe(this, Observer { @@ -36,8 +44,19 @@ class PollingStationActivity : BaseActivity() { tag = PollingStationDetailsFragment.TAG ) }) - replaceFragment(R.id.container, PollingStationSelectionFragment()) + replaceFragment(R.id.container, PollingStationSelectionFragment().apply { + if (countyName != null && pollingStationId > 0) { + arguments = bundleOf( + EXTRA_COUNTY_NAME to countyName, + EXTRA_POLLING_STATION_ID to pollingStationId + ) + } + }) } + companion object { + const val EXTRA_COUNTY_NAME = "extra_county_name" + const val EXTRA_POLLING_STATION_ID = "extra_polling_station_id" + } } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationViewModel.kt index 06e60e94..52ac8eb2 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/section/PollingStationViewModel.kt @@ -201,5 +201,7 @@ class PollingStationViewModel : BaseViewModel() { preferences.completedPollingStationConfig(false) } - + fun registerStationSelection() { + preferences.setHasSelectedStations(true) + } } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/section/VisitedPollingStationsActivity.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/section/VisitedPollingStationsActivity.kt new file mode 100644 index 00000000..9449c721 --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/section/VisitedPollingStationsActivity.kt @@ -0,0 +1,109 @@ +package ro.code4.monitorizarevot.ui.section + +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.Toast +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar +import com.yqritc.recyclerviewflexibledivider.HorizontalDividerItemDecoration +import kotlinx.android.synthetic.main.activity_polling_station.toolbar +import kotlinx.android.synthetic.main.activity_visited_polling_stations.* +import org.koin.android.viewmodel.ext.android.viewModel +import ro.code4.monitorizarevot.R +import ro.code4.monitorizarevot.adapters.VisitedStationsAdapter +import ro.code4.monitorizarevot.helper.Result +import ro.code4.monitorizarevot.helper.changePollingStation +import ro.code4.monitorizarevot.helper.isOnline +import ro.code4.monitorizarevot.ui.base.BaseActivity + +class VisitedPollingStationsActivity : BaseActivity() { + override val layout: Int + get() = R.layout.activity_visited_polling_stations + override val viewModel: VisitedPollingStationsViewModel by viewModel() + + private lateinit var visitedStationsAdapter: VisitedStationsAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setSupportActionBar(toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_minimalist_arrow) + + visitedStationsAdapter = VisitedStationsAdapter(this) { station -> + if (callingActivity?.className == PollingStationActivity::class.java.name) { + val data = Intent().apply { + putExtra( + PollingStationActivity.EXTRA_COUNTY_NAME, + station.countyOrNull()?.name + ) + putExtra( + PollingStationActivity.EXTRA_POLLING_STATION_ID, + station.idPollingStation + ) + } + setResult(RESULT_OK, data) + finish() + } else { + changePollingStation(station.countyOrNull(), station.idPollingStation) + } + } + visitedStations.apply { + layoutManager = LinearLayoutManager(this@VisitedPollingStationsActivity) + visitedStations.adapter = visitedStationsAdapter + addItemDecoration( + HorizontalDividerItemDecoration.Builder(this@VisitedPollingStationsActivity) + .color(Color.TRANSPARENT) + .sizeResId(R.dimen.medium_margin).build() + ) + } + syncButton.setOnClickListener { + // TODO analytics? + if (!this.isOnline()) { + Snackbar.make( + syncButton, + getString(R.string.form_sync_no_internet), + Snackbar.LENGTH_SHORT + ).show() + return@setOnClickListener + } + viewModel.sync() + } + + viewModel.visitedStations.observe(this, Observer { stationsResult -> + stationsResult.handle({ + visitedStations.visibility = View.VISIBLE + loadingIndicator.visibility = View.GONE + visitedStationsAdapter.submitList(it) + }, { + Toast.makeText(this, "Failure: $it", Toast.LENGTH_SHORT).show() + }, { + visitedStations.visibility = View.GONE + loadingIndicator.visibility = View.VISIBLE + }) + }) + viewModel.hasUnsentData.observe(this, Observer { syncStatusResult -> + when (syncStatusResult) { + is Result.Success -> { + syncStatusResult.data?.let { + syncGroup.visibility = if (it) View.VISIBLE else View.GONE + syncSuccessGroup.visibility = if (it) View.GONE else View.VISIBLE + Unit + } ?: hideSyncNotice() + } + else -> { + // keep everything hidden until we have a clear status + hideSyncNotice() + } + } + }) + } + + private fun hideSyncNotice() { + syncGroup.visibility = View.GONE + syncSuccessGroup.visibility = View.GONE + } +} \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/section/VisitedPollingStationsViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/section/VisitedPollingStationsViewModel.kt new file mode 100644 index 00000000..6c82b9ef --- /dev/null +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/section/VisitedPollingStationsViewModel.kt @@ -0,0 +1,69 @@ +package ro.code4.monitorizarevot.ui.section + +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import ro.code4.monitorizarevot.data.pojo.CountyAndPollingStation +import ro.code4.monitorizarevot.helper.Result +import ro.code4.monitorizarevot.repositories.Repository +import ro.code4.monitorizarevot.ui.base.BaseViewModel + +class VisitedPollingStationsViewModel( + private val repository: Repository +) : BaseViewModel() { + private val _hasUnsentData = MediatorLiveData>() + private val _visitedStations = MutableLiveData>>() + val visitedStations: LiveData>> = _visitedStations + val hasUnsentData: LiveData> = _hasUnsentData + + init { + val subscription = repository.getVisitedStations() + .subscribeOn(Schedulers.io()) + .map { dbData -> Result.Success(dbData) as Result> } + .startWith(Result.Loading) + .map { + if (it is Result.Success) { + val sortedData = it.data?.sortedBy { section -> section.observerArrivalTime } + Result.Success(sortedData) + } else { + it + } + } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ + _visitedStations.postValue(it) + }, { }) + disposables.add(subscription) + + updateSyncStatus() + } + + private fun updateSyncStatus() { + val notSyncedQuestionsCount = repository.getNotSyncedQuestions() + val notSyncedNotesCount = repository.getNotSyncedNotes() + val notSyncedPollingStationsCount = repository.getNotSyncedPollingStationsCount() + fun update() { + if (notSyncedQuestionsCount.value != null && notSyncedNotesCount.value != null + && notSyncedPollingStationsCount.value != null + ) { + _hasUnsentData.value = Result.Success( + notSyncedQuestionsCount.value != 0 || notSyncedNotesCount.value != 0 || + notSyncedPollingStationsCount.value != 0 + ) + } else { + _hasUnsentData.value = Result.Loading + } + } + _hasUnsentData.addSource(notSyncedQuestionsCount) { update() } + _hasUnsentData.addSource(notSyncedNotesCount) { update() } + _hasUnsentData.addSource(notSyncedPollingStationsCount) { update() } + } + + fun sync() { + repository.syncData() + } +} + diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/section/details/PollingStationDetailsFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/section/details/PollingStationDetailsFragment.kt index 5040f525..9397c23f 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/section/details/PollingStationDetailsFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/section/details/PollingStationDetailsFragment.kt @@ -36,7 +36,7 @@ class PollingStationDetailsFragment : ViewModelFragment override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - viewModel.pollingStation().observe(this, Observer { + viewModel.pollingStation().observe(viewLifecycleOwner, Observer { pollingStationBarText.text = it }) viewModel.setTitle(getString(R.string.title_polling_station)) @@ -45,10 +45,10 @@ class PollingStationDetailsFragment : ViewModelFragment viewModel.notifyChangeRequested() activity?.onBackPressed() } - viewModel.departureTime().observe(this, Observer { + viewModel.departureTime().observe(viewLifecycleOwner, Observer { departureTime.text = it }) - viewModel.arrivalTime().observe(this, Observer { + viewModel.arrivalTime().observe(viewLifecycleOwner, Observer { arrivalTime.text = it }) arrivalTime.setOnClickListener { @@ -83,7 +83,7 @@ class PollingStationDetailsFragment : ViewModelFragment } }) } - viewModel.selectedPollingStation().observe(this, Observer { + viewModel.selectedPollingStation().observe(viewLifecycleOwner, Observer { setSelection(it) }) setContinueButton() @@ -100,7 +100,7 @@ class PollingStationDetailsFragment : ViewModelFragment private fun showDatePicker(dateTitleId: Int, timeTitleId: Int, listener: DateTimeListener) { val now = Calendar.getInstance() val datePickerDialog = DatePickerDialog( - activity!!, + requireActivity(), DatePickerDialog.OnDateSetListener { _, year, month, day -> showTimePicker(timeTitleId, year, month, day, listener) }, now.get(Calendar.YEAR), now.get(Calendar.MONTH), now.get(Calendar.DAY_OF_MONTH) diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionFragment.kt index 9c72bb3d..419c7c19 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionFragment.kt @@ -1,6 +1,8 @@ package ro.code4.monitorizarevot.ui.section.selection +import android.app.Activity import android.content.Context +import android.content.Intent import android.os.Bundle import android.view.View import android.widget.AdapterView @@ -10,9 +12,11 @@ import kotlinx.android.synthetic.main.fragment_polling_station_selection.* import org.koin.android.viewmodel.ext.android.getSharedViewModel import org.koin.android.viewmodel.ext.android.viewModel import ro.code4.monitorizarevot.R -import ro.code4.monitorizarevot.ui.base.BaseAnalyticsFragment +import ro.code4.monitorizarevot.helper.Result import ro.code4.monitorizarevot.ui.base.ViewModelFragment +import ro.code4.monitorizarevot.ui.section.PollingStationActivity import ro.code4.monitorizarevot.ui.section.PollingStationViewModel +import ro.code4.monitorizarevot.ui.section.VisitedPollingStationsActivity import ro.code4.monitorizarevot.widget.ProgressDialogFragment @@ -24,10 +28,6 @@ class PollingStationSelectionFragment : ViewModelFragment + private var countyCode: String? = null + private var pollingStationId = -1 override fun onAttach(context: Context) { super.onAttach(context) @@ -47,14 +48,17 @@ class PollingStationSelectionFragment : ViewModelFragment progressDialog.dismiss() @@ -77,6 +81,19 @@ class PollingStationSelectionFragment : ViewModelFragment 0) { + val counties = countiesSource.value?.let { countiesResult -> + if (countiesResult is Result.Success) { + countiesResult.data + } else null + } + val targetIndex = + counties?.indexOfFirst { countyName -> countyName == countyCode } ?: -1 + if (targetIndex >= 0) { + updateSelectionDisplay(targetIndex) + } + } }) } @@ -85,6 +102,13 @@ class PollingStationSelectionFragment : ViewModelFragment) {} } - setContinueButton() + setupActionButtons() viewModel.getCounties() } @@ -114,10 +138,41 @@ class PollingStationSelectionFragment : ViewModelFragment 0) { + countyCode = newCountyCode + pollingStationId = newPollingStationNr + for (i in 0 until countySpinnerAdapter.count) { + if (countySpinnerAdapter.getItem(i) == newCountyCode) { + updateSelectionDisplay(i) + } + } + } + } + } + + private fun updateSelectionDisplay(position: Int) { + countySpinner.setSelection(position) + pollingStationNumber.setText(pollingStationId.toString()) + } + + companion object { + val TAG = PollingStationSelectionFragment::class.java.simpleName + const val CHOOSE_SECTION_CODE = 1000 } } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionViewModel.kt index bc253a84..59cf53fb 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/section/selection/PollingStationSelectionViewModel.kt @@ -39,6 +39,7 @@ class PollingStationSelectionViewModel : BaseViewModel() { .doOnSuccess { counties.clear() counties.addAll(it) + counties.sortBy { c -> c.order } } .observeOn(AndroidSchedulers.mainThread()) .subscribe({ @@ -54,11 +55,11 @@ class PollingStationSelectionViewModel : BaseViewModel() { val countyNames = counties.sortedBy { county -> county.order }.map { it.name } if (countyCode.isNullOrBlank()) { - countiesLiveData.postValue(Result.Success(listOf(app.getString(R.string.polling_station_spinner_choose)) + countyNames)) + countiesLiveData.postValue(Result.Success(listOf(app.getString(R.string.polling_station_spinner_choose)) + countyNames.toList())) } else { hadSelectedCounty = true val selectedCountyIndex = counties.indexOfFirst { it.code == countyCode } - countiesLiveData.postValue(Result.Success(countyNames)) + countiesLiveData.postValue(Result.Success(countyNames.toList())) if (selectedCountyIndex >= 0) { selectionLiveData.postValue(Pair(selectedCountyIndex, pollingStationNumber)) @@ -75,5 +76,6 @@ class PollingStationSelectionViewModel : BaseViewModel() { countiesLiveData.postValue(Result.Failure(throwable)) } - + fun hasSelectedStation() = + sharedPreferences.getHasSelectedStations() } \ No newline at end of file diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/settings/AboutFragment.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/settings/AboutFragment.kt index eafd7c40..f63e34f5 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/settings/AboutFragment.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/settings/AboutFragment.kt @@ -7,13 +7,16 @@ import android.text.method.LinkMovementMethod import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import com.google.firebase.remoteconfig.FirebaseRemoteConfig import kotlinx.android.synthetic.main.fragment_about.* import ro.code4.monitorizarevot.BuildConfig import ro.code4.monitorizarevot.R import ro.code4.monitorizarevot.analytics.Event import ro.code4.monitorizarevot.analytics.Param import ro.code4.monitorizarevot.analytics.ParamKey +import ro.code4.monitorizarevot.helper.Constants import ro.code4.monitorizarevot.helper.browse +import ro.code4.monitorizarevot.helper.getStringOrDefault import ro.code4.monitorizarevot.helper.logW import ro.code4.monitorizarevot.helper.toHtml import ro.code4.monitorizarevot.ui.base.BaseAnalyticsFragment @@ -26,6 +29,24 @@ class AboutFragment : BaseAnalyticsFragment() { val TAG = AboutFragment::class.java.simpleName } + private val remoteConfig = runCatching { FirebaseRemoteConfig.getInstance() }.getOrNull() + private val contactEmailUri by lazy { + Uri.fromParts( + "mailto", + remoteConfig.getStringOrDefault( + Constants.REMOTE_CONFIG_CONTACT_EMAIL, + BuildConfig.SUPPORT_EMAIL + ), + null + ) + } + private val privacyPolicyUrl by lazy { + remoteConfig.getStringOrDefault( + Constants.REMOTE_CONFIG_PRIVACY_POLICY_URL, + BuildConfig.PRIVACY_WEB_URL + ) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -37,7 +58,7 @@ class AboutFragment : BaseAnalyticsFragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - appVersion.text = context?.getString(R.string.about_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) + appVersion.text = getString(R.string.about_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) content.text = getString(R.string.about_content).toHtml() content.movementMethod = LinkMovementMethod.getInstance() @@ -52,14 +73,14 @@ class AboutFragment : BaseAnalyticsFragment() { private fun onChangeLanguageClicked(view: View) { logClickEvent(view) val languageSelector = AboutLanguageSelectorFragment() - languageSelector.show(requireFragmentManager(), AboutLanguageSelectorFragment.TAG) + languageSelector.show(parentFragmentManager, AboutLanguageSelectorFragment.TAG) } private fun onContactClicked(view: View) { logClickEvent(view) val emailIntent = Intent( Intent.ACTION_SENDTO, - Uri.fromParts("mailto", BuildConfig.SUPPORT_EMAIL, null) + contactEmailUri ) startActivity( Intent.createChooser( @@ -71,9 +92,9 @@ class AboutFragment : BaseAnalyticsFragment() { private fun onViewPolicyClicked(view: View) { logClickEvent(view) - val result = context?.browse(BuildConfig.PRIVACY_WEB_URL) - if (!result!!) { - logW("No app to view " + BuildConfig.PRIVACY_WEB_URL) + val result = requireContext().browse(privacyPolicyUrl) + if (!result) { + logW("No app to view $privacyPolicyUrl") } } diff --git a/app/src/main/java/ro/code4/monitorizarevot/ui/splashscreen/SplashScreenViewModel.kt b/app/src/main/java/ro/code4/monitorizarevot/ui/splashscreen/SplashScreenViewModel.kt index e90e3a28..4e6536bd 100644 --- a/app/src/main/java/ro/code4/monitorizarevot/ui/splashscreen/SplashScreenViewModel.kt +++ b/app/src/main/java/ro/code4/monitorizarevot/ui/splashscreen/SplashScreenViewModel.kt @@ -7,15 +7,15 @@ import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings import org.koin.core.inject import ro.code4.monitorizarevot.BuildConfig import ro.code4.monitorizarevot.R -import ro.code4.monitorizarevot.helper.SingleLiveEvent -import ro.code4.monitorizarevot.helper.getToken -import ro.code4.monitorizarevot.helper.hasCompletedOnboarding -import ro.code4.monitorizarevot.helper.isPollingStationConfigCompleted +import ro.code4.monitorizarevot.helper.* +import ro.code4.monitorizarevot.repositories.Repository import ro.code4.monitorizarevot.ui.base.BaseViewModel class SplashScreenViewModel : BaseViewModel() { private val sharedPreferences: SharedPreferences by inject() + private val repository: Repository by inject() private val loginLiveData = SingleLiveEvent() + private val remoteConfig = runCatching { FirebaseRemoteConfig.getInstance() }.getOrNull() fun loginLiveData(): LiveData = loginLiveData @@ -24,27 +24,26 @@ class SplashScreenViewModel : BaseViewModel() { } private fun remoteConfiguration() { - try { - - - FirebaseRemoteConfig.getInstance().apply { + remoteConfig?.let { + it.apply { + val minInterval = if (BuildConfig.DEBUG) 0 else 3600L val configSettings = FirebaseRemoteConfigSettings.Builder() + .setMinimumFetchIntervalInSeconds(minInterval) .build() + setConfigSettingsAsync(configSettings) setDefaultsAsync(R.xml.remote_config_defaults) - fetch(if (BuildConfig.DEBUG) 0 else 3600) + fetchAndActivate() .addOnCompleteListener { - if (it.isSuccessful) { - FirebaseRemoteConfig.getInstance().activate() - } + checkResetDB() checkLogin() } - } - } catch (e: Exception) { - checkLogin() } + if (remoteConfig == null) { + checkLogin() + } } private fun checkLogin() { @@ -64,4 +63,23 @@ class SplashScreenViewModel : BaseViewModel() { val isPollingStationConfigCompleted: Boolean, val onboardingCompleted: Boolean ) + + private fun checkResetDB() { + val roundStartTimestamp = try { + FirebaseRemoteConfig.getInstance().getLong(Constants.REMOTE_CONFIG_ROUND_START_TIMESTAMP) + } catch (e: Exception) { + 0L + } + + val lastDbReset = sharedPreferences.getLastDbResetTimestamp() + + val currentTimestamp = System.currentTimeMillis()/1000 + + if (roundStartTimestamp in 1 until currentTimestamp && (lastDbReset == 0L || lastDbReset < roundStartTimestamp)) { + sharedPreferences.clearUserPrefs() + repository.clearDBData() + + sharedPreferences.setLastDbResetTimestamp(currentTimestamp) + } + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_visited_stations.xml b/app/src/main/res/drawable/ic_menu_visited_stations.xml new file mode 100644 index 00000000..431c71f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_visited_stations.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_minimalist_arrow.xml b/app/src/main/res/drawable/ic_minimalist_arrow.xml new file mode 100644 index 00000000..e550e207 --- /dev/null +++ b/app/src/main/res/drawable/ic_minimalist_arrow.xml @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_note_file_delete.xml b/app/src/main/res/drawable/ic_note_file_delete.xml new file mode 100644 index 00000000..f4315175 --- /dev/null +++ b/app/src/main/res/drawable/ic_note_file_delete.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_sync_label_icon.xml b/app/src/main/res/drawable/ic_sync_label_icon.xml new file mode 100644 index 00000000..c4ff84b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_sync_label_icon.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/thin_border_rectangle.xml b/app/src/main/res/drawable/thin_border_rectangle.xml new file mode 100644 index 00000000..a3e960da --- /dev/null +++ b/app/src/main/res/drawable/thin_border_rectangle.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index a6f8ed6e..604ba5f0 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -23,18 +23,6 @@ app:headerLayout="@layout/nav_header_main" app:menu="@menu/main_menu"> - - diff --git a/app/src/main/res/layout/activity_visited_polling_stations.xml b/app/src/main/res/layout/activity_visited_polling_stations.xml new file mode 100644 index 00000000..a3e89e69 --- /dev/null +++ b/app/src/main/res/layout/activity_visited_polling_stations.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +