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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_forms.xml b/app/src/main/res/layout/fragment_forms.xml
index 389d57cb..3f41f83c 100644
--- a/app/src/main/res/layout/fragment_forms.xml
+++ b/app/src/main/res/layout/fragment_forms.xml
@@ -27,37 +27,39 @@
+ app:layout_constraintTop_toTopOf="@id/syncInfo"
+ app:layout_constraintVertical_bias="0"
+ app:srcCompat="@drawable/ic_sync_label_icon" />
+ app:layout_constraintBottom_toTopOf="@id/syncButton"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_chainStyle="packed"
+ app:layout_constraintStart_toEndOf="@id/syncIcon"
+ app:layout_constraintTop_toBottomOf="@id/formsList" />
@@ -68,7 +70,47 @@
android:layout_height="0dp"
android:visibility="gone"
app:constraint_referenced_ids="syncButton,syncIcon,syncInfo"
+ tools:layout_editor_absoluteX="16dp"
+ tools:layout_editor_absoluteY="24dp"
tools:visibility="visible" />
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_note_detail.xml b/app/src/main/res/layout/fragment_note_detail.xml
new file mode 100644
index 00000000..4e5fa2a0
--- /dev/null
+++ b/app/src/main/res/layout/fragment_note_detail.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_polling_station_selection.xml b/app/src/main/res/layout/fragment_polling_station_selection.xml
index c996dc2a..664d6797 100644
--- a/app/src/main/res/layout/fragment_polling_station_selection.xml
+++ b/app/src/main/res/layout/fragment_polling_station_selection.xml
@@ -18,10 +18,10 @@
android:id="@+id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:contentDescription="@string/content_icon_building"
android:layout_marginTop="@dimen/xbig_margin"
- app:layout_constraintEnd_toEndOf="parent"
+ android:contentDescription="@string/content_icon_building"
android:src="@drawable/ic_polling_station"
+ app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
@@ -39,7 +39,6 @@
app:layout_constraintVertical_chainStyle="packed" />
-
-
+ android:orientation="vertical">
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_visited_polling_stations.xml b/app/src/main/res/layout/fragment_visited_polling_stations.xml
new file mode 100644
index 00000000..f278d678
--- /dev/null
+++ b/app/src/main/res/layout/fragment_visited_polling_stations.xml
@@ -0,0 +1,5 @@
+
+
diff --git a/app/src/main/res/layout/include_note_filename.xml b/app/src/main/res/layout/include_note_filename.xml
new file mode 100644
index 00000000..3c478151
--- /dev/null
+++ b/app/src/main/res/layout/include_note_filename.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_note.xml b/app/src/main/res/layout/item_note.xml
index 5e46c0f9..6739a469 100644
--- a/app/src/main/res/layout/item_note.xml
+++ b/app/src/main/res/layout/item_note.xml
@@ -2,11 +2,12 @@
+ tools:text="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book." />
+ android:ellipsize="end"
+ app:layout_constraintEnd_toStartOf="@id/noteDate"
+ app:layout_constraintHorizontal_chainStyle="spread_inside"
+ app:layout_constraintBaseline_toBaselineOf="@id/noteDate"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/noteText"
+ tools:text="Form5 - Q5" />
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintHorizontal_chainStyle="spread_inside"
+ app:layout_constraintStart_toEndOf="@id/formAndQuestionIdentifier"
+ app:layout_constraintTop_toBottomOf="@id/noteText"
+ tools:text="14/05 20:29" />
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_note_details_image.xml b/app/src/main/res/layout/item_note_details_image.xml
new file mode 100644
index 00000000..4dc096ca
--- /dev/null
+++ b/app/src/main/res/layout/item_note_details_image.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_note_details_text.xml b/app/src/main/res/layout/item_note_details_text.xml
new file mode 100644
index 00000000..41fbef78
--- /dev/null
+++ b/app/src/main/res/layout/item_note_details_text.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_visited_section.xml b/app/src/main/res/layout/item_visited_section.xml
new file mode 100644
index 00000000..47d964e6
--- /dev/null
+++ b/app/src/main/res/layout/item_visited_section.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/layout_edit_note.xml b/app/src/main/res/layout/layout_edit_note.xml
index 108db971..ce3c0d4b 100644
--- a/app/src/main/res/layout/layout_edit_note.xml
+++ b/app/src/main/res/layout/layout_edit_note.xml
@@ -1,7 +1,6 @@
@@ -34,32 +33,27 @@
android:padding="@dimen/medium_margin"
app:layout_constraintTop_toBottomOf="@id/title" />
-
+ app:layout_constraintTop_toBottomOf="@id/noteInput" />
+ app:layout_constraintTop_toBottomOf="@id/noteFilesContainer" />
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-ro-rRO/strings.xml b/app/src/main/res/values-ro-rRO/strings.xml
index 8a2f755f..e1200319 100644
--- a/app/src/main/res/values-ro-rRO/strings.xml
+++ b/app/src/main/res/values-ro-rRO/strings.xml
@@ -35,6 +35,7 @@
Alege secția
Detalii secție
+ Secții de vot vizitate
Formulare
Adaugă notă
Ghid
@@ -47,10 +48,12 @@
Schimbă secţia
Ghidul observatorului
Siguranța la vot
+ Feedback observatori
Apelează TelVerde
Despre
Delogare
Despre
+ Secții de vot vizitate
Alege secția în care te afli
@@ -59,6 +62,7 @@
Alege
Introduceți numărul secției
Continuă
+ Alege din secțiile vizitate
Alege județul
Introdu numărul secției
@@ -84,11 +88,14 @@
Alege bărbat sau femeie
Alege ora sosirii
Ora plecării nu poate fi mai mică decât ora sosirii
+ Secţia de votare %1$d %2$s
+ Aici găsești secțiile pe care le-ai vizitat deja. Apasă pe o secție să verifici sau să editezi răspunsuri.
Adaugă notă
- Unele întrebări nu au fost sincronizate. Apasă butonul pentru a trimite răspunsurile din nou.
+ Unele răspunsuri sau note nu au fost sincronizate. Apasă butonul pentru a trimite răspunsurile din nou.
+ Toate răspunsurile și notele au fost sincronizate cu succes
Sincronizează datele manual
Ai nevoie de o conexiune la internet pentru a sincroniza datele!
@@ -113,15 +120,19 @@
Scrie aici
Adaugă foto/video
Introdu o descriere sau alege un fişier
- Permisiunea este necesară pentru a putea selecta o resursă
+ Fișierul nu a putut fi atașat. Verificați permisiunile aplicației și spațiul de stocare disponibil
+ Unele fișiere nu au putut fi atașate. Verificați permisiunile aplicației și spațiul de stocare disponibil
Încarcă din galerie
Fotografiază
Înregistrează video
Selectează fotografia
Te rugăm să instalezi un manager de fișiere.
Trimite
-
Istoricul notelor
+ Videoclipul atașat nu are un preview. Apăsați aici pentru a îl vizualiza într-un player extern!
+ Nu există aplicații capabile să afișeze acest videoclip!
+ Ai nevoie de o conexiune la internet pentru a vedea fișierele atașate ale unei note sincronizate!
+ Formular %1$s - Întrebare %2$s
Simbol clădire
@@ -150,4 +161,4 @@
Politică de confidențialitate
Trimiteți email prin
v%1$S build %2$d aplicație dezvoltată de
-
\ No newline at end of file
+
diff --git a/app/src/main/res/values/analytics_strings.xml b/app/src/main/res/values/analytics_strings.xml
index 07f44019..c38ddf5d 100644
--- a/app/src/main/res/values/analytics_strings.xml
+++ b/app/src/main/res/values/analytics_strings.xml
@@ -4,6 +4,7 @@
Login
Polling station chooser
Polling station details
+ Visited polling stations
Forms
Questions
Question
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index c79225fb..7ee5e98d 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -61,5 +61,6 @@
26dp
16dp
+ 32dp
10dp
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3b39e68b..3665dbfb 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -35,6 +35,7 @@
Choose your polling station
Polling station details
+ Visited polling stations
Forms
Notes
Guide
@@ -47,9 +48,11 @@
Change the polling station
Observer\'s guide
Voting safety
+ Observers feedback
Call the Hotline
About
Logout
+ My visited stations
About
@@ -59,6 +62,7 @@
Choose
Fill in your polling station number
Continue
+ Select from visited stations
Choose the county/district
Fill in the polling station number
@@ -84,11 +88,14 @@
Choose man or woman
Select the time of arrival
Arrival needs to be before departure
+ Polling station %1$d %2$s
+ This is the list of stations you already visited. Tap one to view or edit its submitted answers.
Add a message
It appears that some of the questions have not been synchronised. Tap the button below to send the answers again
+ All the answers and notes were synchronized successfully
Synchronize data manually
You need to be connected to the internet to be able to sync the data!
@@ -113,15 +120,19 @@
Type here
Add photo or video
Add a description or choose a file
- We need your permission to select a resource
+ Unable to attach the file. Check the app permissions and the storage space available
+ Unable to attach some of the files. Check the app permissions and the storage space available
Load from gallery
Take photo
Record video
Select Photo
Please install a file manager.
Submit
-
+ The attached video doesn\'t have a preview to show. Tap here to see it in an external player!
+ There aren\'t any apps capable of showing the video!
+ You need to be connected to the internet to be able to see the attached files of a synchronized note!
Notes history
+ Form %1$s - Question %2$s
Building icon
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
index 168b1b15..f1c3725b 100644
--- a/app/src/main/res/values/styles.xml
+++ b/app/src/main/res/values/styles.xml
@@ -144,6 +144,11 @@
- 12sp
+
+