working kotlin mobile app
This commit is contained in:
@@ -2,22 +2,25 @@ package com.vaultdrop.mobile.data.local
|
|||||||
|
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import com.vaultdrop.mobile.data.local.dao.FileDao
|
||||||
import com.vaultdrop.mobile.data.local.dao.FolderDao
|
import com.vaultdrop.mobile.data.local.dao.FolderDao
|
||||||
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
|
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||||
import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
|
import com.vaultdrop.mobile.data.local.entity.UserPreferenceEntity
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* DB SQLite locale, `dot.db` (même nom que la version Expo).
|
* DB SQLite locale, `dot.db` (même nom que la version Expo).
|
||||||
* v1: folders ; v2: user_preferences.
|
* v1: folders ; v2: user_preferences ; v3: files.
|
||||||
*/
|
*/
|
||||||
@Database(
|
@Database(
|
||||||
entities = [FolderEntity::class, UserPreferenceEntity::class],
|
entities = [FolderEntity::class, UserPreferenceEntity::class, FileEntity::class],
|
||||||
version = 2,
|
version = 3,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
abstract fun folderDao(): FolderDao
|
abstract fun folderDao(): FolderDao
|
||||||
abstract fun userPreferenceDao(): UserPreferenceDao
|
abstract fun userPreferenceDao(): UserPreferenceDao
|
||||||
|
abstract fun fileDao(): FileDao
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.vaultdrop.mobile.data.local.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Upsert
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface FileDao {
|
||||||
|
|
||||||
|
@Query("SELECT * FROM files WHERE folder_resource_id = :folderResourceId AND \"exists\" = 1 ORDER BY name ASC")
|
||||||
|
fun observeByFolder(folderResourceId: String): Flow<List<FileEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM files WHERE resource_id = :resourceId LIMIT 1")
|
||||||
|
suspend fun getByResourceId(resourceId: String): FileEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM files WHERE uri = :uri LIMIT 1")
|
||||||
|
suspend fun getByUri(uri: String): FileEntity?
|
||||||
|
|
||||||
|
@Upsert
|
||||||
|
suspend fun upsert(file: FileEntity)
|
||||||
|
|
||||||
|
@Query("DELETE FROM files WHERE resource_id = :resourceId")
|
||||||
|
suspend fun remove(resourceId: String)
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@ interface FolderDao {
|
|||||||
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
|
@Query("SELECT * FROM folders WHERE parent_resource_id IS NULL ORDER BY name ASC")
|
||||||
suspend fun getRootFolders(): List<FolderEntity>
|
suspend fun getRootFolders(): List<FolderEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId AND \"exists\" = 1 ORDER BY name ASC")
|
||||||
|
fun observeByParent(parentResourceId: String): Flow<List<FolderEntity>>
|
||||||
|
|
||||||
@Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId ORDER BY name ASC")
|
@Query("SELECT * FROM folders WHERE parent_resource_id = :parentResourceId ORDER BY name ASC")
|
||||||
suspend fun getByParent(parentResourceId: String): List<FolderEntity>
|
suspend fun getByParent(parentResourceId: String): List<FolderEntity>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.vaultdrop.mobile.data.local.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Miroir de `FileRow` (mobile/services/db/types.ts).
|
||||||
|
*
|
||||||
|
* `uri` NULL = fichier cloud-only (pas de copie physique locale) ; l'index
|
||||||
|
* unique est posé sur la colonne directement (SQLite traite les NULL comme
|
||||||
|
* distincts — équivalent de l'index partiel JS `WHERE uri IS NOT NULL`).
|
||||||
|
*
|
||||||
|
* `folder_resource_id` = resource_id du dossier hôte (jamais NULL côté SQLite
|
||||||
|
* JS ; les fichiers racine sont rares et hors scope V1).
|
||||||
|
*/
|
||||||
|
@Entity(
|
||||||
|
tableName = "files",
|
||||||
|
indices = [
|
||||||
|
Index(value = ["resource_id"], unique = true),
|
||||||
|
Index(value = ["uri"], unique = true),
|
||||||
|
Index(value = ["folder_resource_id"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
data class FileEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0L,
|
||||||
|
@ColumnInfo(name = "resource_id")
|
||||||
|
val resourceId: String,
|
||||||
|
@ColumnInfo(name = "uri")
|
||||||
|
val uri: String? = null,
|
||||||
|
@ColumnInfo(name = "name")
|
||||||
|
val name: String,
|
||||||
|
@ColumnInfo(name = "folder_resource_id")
|
||||||
|
val folderResourceId: String,
|
||||||
|
@ColumnInfo(name = "extension")
|
||||||
|
val extension: String? = null,
|
||||||
|
@ColumnInfo(name = "size")
|
||||||
|
val size: Long,
|
||||||
|
@ColumnInfo(name = "mime_type")
|
||||||
|
val mimeType: String? = null,
|
||||||
|
@ColumnInfo(name = "exists")
|
||||||
|
val exists: Int? = null,
|
||||||
|
@ColumnInfo(name = "last_modified")
|
||||||
|
val lastModified: Long? = null,
|
||||||
|
@ColumnInfo(name = "owner_id")
|
||||||
|
val ownerId: String? = null,
|
||||||
|
@ColumnInfo(name = "sync_status")
|
||||||
|
val syncStatus: String = "local",
|
||||||
|
@ColumnInfo(name = "added_at")
|
||||||
|
val addedAt: Long,
|
||||||
|
@ColumnInfo(name = "updated_at")
|
||||||
|
val updatedAt: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Placement d'un fichier — même convention que `FolderStatus`. */
|
||||||
|
object FileStatus {
|
||||||
|
const val LOCAL = "local"
|
||||||
|
const val CLOUD = "cloud"
|
||||||
|
const val LOCAL_CLOUD = "local-cloud"
|
||||||
|
}
|
||||||
+30
-1
@@ -9,6 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
* v1 : table `folders` (créée par Room à partir de l'entité).
|
* v1 : table `folders` (créée par Room à partir de l'entité).
|
||||||
* v2 : table `user_preferences` — clé/valeur pour l'identité device, le miroir
|
* v2 : table `user_preferences` — clé/valeur pour l'identité device, le miroir
|
||||||
* du compte actif et les préférences utilisateur.
|
* du compte actif et les préférences utilisateur.
|
||||||
|
* v3 : table `files` — miroir de `FileRow` JS (métadonnées locales + cloud).
|
||||||
*/
|
*/
|
||||||
object Migrations {
|
object Migrations {
|
||||||
|
|
||||||
@@ -27,5 +28,33 @@ object Migrations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2)
|
private val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS `files` (
|
||||||
|
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`resource_id` TEXT NOT NULL,
|
||||||
|
`uri` TEXT,
|
||||||
|
`name` TEXT NOT NULL,
|
||||||
|
`folder_resource_id` TEXT NOT NULL,
|
||||||
|
`extension` TEXT,
|
||||||
|
`size` INTEGER NOT NULL,
|
||||||
|
`mime_type` TEXT,
|
||||||
|
`exists` INTEGER,
|
||||||
|
`last_modified` INTEGER,
|
||||||
|
`owner_id` TEXT,
|
||||||
|
`sync_status` TEXT NOT NULL DEFAULT 'local',
|
||||||
|
`added_at` INTEGER NOT NULL,
|
||||||
|
`updated_at` INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_files_resource_id` ON `files` (`resource_id`)")
|
||||||
|
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_files_uri` ON `files` (`uri`)")
|
||||||
|
db.execSQL("CREATE INDEX IF NOT EXISTS `index_files_folder_resource_id` ON `files` (`folder_resource_id`)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ALL: Array<Migration> = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.squareup.moshi.Moshi
|
|||||||
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
|
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
|
||||||
import com.vaultdrop.mobile.data.remote.dto.ApiErrorEnvelope
|
import com.vaultdrop.mobile.data.remote.dto.ApiErrorEnvelope
|
||||||
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
|
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.FileDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.FolderDto
|
import com.vaultdrop.mobile.data.remote.dto.FolderDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
|
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
|
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
|
||||||
@@ -48,6 +49,9 @@ class ApiClient @Inject constructor(
|
|||||||
|
|
||||||
suspend fun listFolders(): List<FolderDto> = unwrap({ apiService.listFolders() })
|
suspend fun listFolders(): List<FolderDto> = unwrap({ apiService.listFolders() })
|
||||||
|
|
||||||
|
suspend fun listFiles(folderId: String? = null, page: Int? = null, pageSize: Int? = null): List<FileDto> =
|
||||||
|
unwrap({ apiService.listFiles(folderId, page, pageSize) })
|
||||||
|
|
||||||
suspend fun registerDevice(deviceId: String): String =
|
suspend fun registerDevice(deviceId: String): String =
|
||||||
unwrap({ apiService.registerDevice(DeviceRegistrationDto(deviceId)) }).deviceId
|
unwrap({ apiService.registerDevice(DeviceRegistrationDto(deviceId)) }).deviceId
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.vaultdrop.mobile.data.remote
|
|||||||
|
|
||||||
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
|
import com.vaultdrop.mobile.data.remote.dto.ApiEnvelope
|
||||||
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
|
import com.vaultdrop.mobile.data.remote.dto.DeviceRegistrationDto
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.FileDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.FolderDto
|
import com.vaultdrop.mobile.data.remote.dto.FolderDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
|
import com.vaultdrop.mobile.data.remote.dto.LoginRequestDto
|
||||||
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
|
import com.vaultdrop.mobile.data.remote.dto.LoginResponseDto
|
||||||
@@ -9,6 +10,7 @@ import retrofit2.Response
|
|||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contrat HTTP V1 — copie de `mobile/api/client.ts` (le client mobile est la
|
* Contrat HTTP V1 — copie de `mobile/api/client.ts` (le client mobile est la
|
||||||
@@ -20,6 +22,14 @@ interface ApiService {
|
|||||||
@GET("files/folders")
|
@GET("files/folders")
|
||||||
suspend fun listFolders(): Response<ApiEnvelope<List<FolderDto>>>
|
suspend fun listFolders(): Response<ApiEnvelope<List<FolderDto>>>
|
||||||
|
|
||||||
|
/** Équivalent de `GET /files` (client.ts). Paginé (`meta`). */
|
||||||
|
@GET("files")
|
||||||
|
suspend fun listFiles(
|
||||||
|
@Query("folderId") folderId: String?,
|
||||||
|
@Query("page") page: Int?,
|
||||||
|
@Query("pageSize") pageSize: Int?,
|
||||||
|
): Response<ApiEnvelope<List<FileDto>>>
|
||||||
|
|
||||||
/** Enregistrement idempotent du device — aucun token émis. */
|
/** Enregistrement idempotent du device — aucun token émis. */
|
||||||
@POST("devices")
|
@POST("devices")
|
||||||
suspend fun registerDevice(
|
suspend fun registerDevice(
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ data class ApiErrorEnvelope(
|
|||||||
@Json(name = "error") val error: ApiErrorBody? = null,
|
@Json(name = "error") val error: ApiErrorBody? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Miroir de `api/types.ts` — `FileDto`. */
|
||||||
|
data class FileDto(
|
||||||
|
@Json(name = "id") val id: String,
|
||||||
|
@Json(name = "name") val name: String,
|
||||||
|
@Json(name = "size") val size: Long,
|
||||||
|
@Json(name = "mimeType") val mimeType: String? = null,
|
||||||
|
@Json(name = "folderId") val folderId: String? = null,
|
||||||
|
@Json(name = "tags") val tags: List<String>? = null,
|
||||||
|
@Json(name = "createdAt") val createdAt: String? = null,
|
||||||
|
@Json(name = "updatedAt") val updatedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
/** Miroir de `api/types.ts` — `FolderDto`. */
|
/** Miroir de `api/types.ts` — `FolderDto`. */
|
||||||
data class FolderDto(
|
data class FolderDto(
|
||||||
@Json(name = "id") val id: String,
|
@Json(name = "id") val id: String,
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package com.vaultdrop.mobile.data.repository
|
||||||
|
|
||||||
|
import com.vaultdrop.mobile.data.local.dao.FileDao
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileStatus
|
||||||
|
import com.vaultdrop.mobile.data.remote.ApiClient
|
||||||
|
import com.vaultdrop.mobile.data.remote.dto.FileDto
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Miroir de `services/db/repositories/files.ts`. Local-first : l'UI observe
|
||||||
|
* Room ; le réseau rafraîchit les fichiers cloud d'un dossier (`GET /files`).
|
||||||
|
*
|
||||||
|
* Les fichiers renvoyés par le serveur sont cloud-only (uri = NULL) :
|
||||||
|
* aucune copie physique locale, mirror de `saveFile(..., syncStatus='cloud')`.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class FileRepository @Inject constructor(
|
||||||
|
private val fileDao: FileDao,
|
||||||
|
private val apiClient: ApiClient,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Fichiers visibles du dossier, locaux + cloud — source de l'UI. */
|
||||||
|
fun observeFiles(folderResourceId: String): Flow<List<FileEntity>> =
|
||||||
|
fileDao.observeByFolder(folderResourceId)
|
||||||
|
|
||||||
|
suspend fun getFile(resourceId: String): FileEntity? =
|
||||||
|
fileDao.getByResourceId(resourceId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /files?folderId=...` (1re page, tri serveur) puis upsert cloud de
|
||||||
|
* chaque fichier. Ne supprime jamais de lignes locales.
|
||||||
|
*/
|
||||||
|
suspend fun refreshFromServer(folderResourceId: String) {
|
||||||
|
val files = apiClient.listFiles(folderId = folderResourceId, pageSize = PAGE_SIZE)
|
||||||
|
if (files.isEmpty()) return
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
files.forEach { dto ->
|
||||||
|
fileDao.upsert(toEntity(dto, folderResourceId, now))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun toEntity(dto: FileDto, folderResourceId: String, now: Long): FileEntity {
|
||||||
|
val existing = fileDao.getByResourceId(dto.id)
|
||||||
|
return FileEntity(
|
||||||
|
id = existing?.id ?: 0L,
|
||||||
|
resourceId = dto.id,
|
||||||
|
uri = null,
|
||||||
|
name = dto.name,
|
||||||
|
folderResourceId = dto.folderId ?: folderResourceId,
|
||||||
|
extension = dto.name.substringAfterLast('.', "").takeIf { it.isNotEmpty() && it != dto.name },
|
||||||
|
size = dto.size,
|
||||||
|
mimeType = dto.mimeType,
|
||||||
|
exists = 1,
|
||||||
|
lastModified = existing?.lastModified,
|
||||||
|
ownerId = existing?.ownerId,
|
||||||
|
syncStatus = existing?.syncStatus ?: FileStatus.CLOUD,
|
||||||
|
addedAt = existing?.addedAt ?: now,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PAGE_SIZE = 50
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
@@ -23,6 +23,10 @@ class FolderRepository @Inject constructor(
|
|||||||
|
|
||||||
fun observeRootFolders(): Flow<List<FolderEntity>> = folderDao.observeRootFolders()
|
fun observeRootFolders(): Flow<List<FolderEntity>> = folderDao.observeRootFolders()
|
||||||
|
|
||||||
|
/** Sous-dossiers visibles d'un dossier (filtre `exists = 1` dans le DAO). */
|
||||||
|
fun observeSubFolders(parentResourceId: String): Flow<List<FolderEntity>> =
|
||||||
|
folderDao.observeByParent(parentResourceId)
|
||||||
|
|
||||||
suspend fun getRootFolders(): List<FolderEntity> = folderDao.getRootFolders()
|
suspend fun getRootFolders(): List<FolderEntity> = folderDao.getRootFolders()
|
||||||
|
|
||||||
suspend fun getFolder(resourceId: String): FolderEntity? =
|
suspend fun getFolder(resourceId: String): FolderEntity? =
|
||||||
@@ -43,6 +47,7 @@ class FolderRepository @Inject constructor(
|
|||||||
private suspend fun toEntity(dto: FolderDto, now: Long): FolderEntity {
|
private suspend fun toEntity(dto: FolderDto, now: Long): FolderEntity {
|
||||||
val existing = folderDao.getByResourceId(dto.id)
|
val existing = folderDao.getByResourceId(dto.id)
|
||||||
return FolderEntity(
|
return FolderEntity(
|
||||||
|
id = existing?.id ?: 0L,
|
||||||
resourceId = dto.id,
|
resourceId = dto.id,
|
||||||
uri = null,
|
uri = null,
|
||||||
name = dto.name,
|
name = dto.name,
|
||||||
@@ -68,6 +73,7 @@ class FolderRepository @Inject constructor(
|
|||||||
?: input.uri?.let { folderDao.getByUri(it) }
|
?: input.uri?.let { folderDao.getByUri(it) }
|
||||||
|
|
||||||
val entity = FolderEntity(
|
val entity = FolderEntity(
|
||||||
|
id = existing?.id ?: 0L,
|
||||||
resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(),
|
resourceId = existing?.resourceId ?: input.resourceId ?: generateId.newResourceId(),
|
||||||
uri = input.uri,
|
uri = input.uri,
|
||||||
name = input.name,
|
name = input.name,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.vaultdrop.mobile.di
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.vaultdrop.mobile.data.local.AppDatabase
|
import com.vaultdrop.mobile.data.local.AppDatabase
|
||||||
|
import com.vaultdrop.mobile.data.local.dao.FileDao
|
||||||
import com.vaultdrop.mobile.data.local.dao.FolderDao
|
import com.vaultdrop.mobile.data.local.dao.FolderDao
|
||||||
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
|
import com.vaultdrop.mobile.data.local.dao.UserPreferenceDao
|
||||||
import com.vaultdrop.mobile.data.local.migration.Migrations
|
import com.vaultdrop.mobile.data.local.migration.Migrations
|
||||||
@@ -29,4 +30,7 @@ object DatabaseModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideUserPreferenceDao(db: AppDatabase): UserPreferenceDao = db.userPreferenceDao()
|
fun provideUserPreferenceDao(db: AppDatabase): UserPreferenceDao = db.userPreferenceDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideFileDao(db: AppDatabase): FileDao = db.fileDao()
|
||||||
}
|
}
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
package com.vaultdrop.mobile.ui.folderdetail
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.InsertDriveFile
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vaultdrop.mobile.R
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun FolderDetailScreen(
|
||||||
|
folderResourceId: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onOpenFolder: (String) -> Unit,
|
||||||
|
viewModel: FolderDetailViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.folderMissing) {
|
||||||
|
if (uiState.folderMissing) onBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(uiState.folder?.name ?: stringResource(R.string.folder)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = viewModel::refresh) {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = stringResource(R.string.refresh))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { padding ->
|
||||||
|
FolderDetailContent(
|
||||||
|
subFolders = uiState.subFolders,
|
||||||
|
files = uiState.files,
|
||||||
|
isRefreshing = uiState.isRefreshing,
|
||||||
|
error = uiState.error,
|
||||||
|
onOpenFolder = onOpenFolder,
|
||||||
|
modifier = Modifier.padding(padding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FolderDetailContent(
|
||||||
|
subFolders: List<FolderEntity>,
|
||||||
|
files: List<FileEntity>,
|
||||||
|
isRefreshing: Boolean,
|
||||||
|
error: String?,
|
||||||
|
onOpenFolder: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val empty = subFolders.isEmpty() && files.isEmpty() && !isRefreshing
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(subFolders, key = { it.resourceId }) { folder ->
|
||||||
|
FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) })
|
||||||
|
}
|
||||||
|
items(files, key = { it.resourceId }) { file ->
|
||||||
|
FileRow(file)
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
empty -> {
|
||||||
|
item(key = "empty") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.empty_folder),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (error != null) {
|
||||||
|
item(key = "error") {
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Folder,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = folder.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.folder),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FileRow(file: FileEntity) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.InsertDriveFile,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = file.name,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatSize(file.size),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Miroir de `formatSize` (app/folder/[id].tsx). */
|
||||||
|
@Composable
|
||||||
|
private fun formatSize(bytes: Long): String {
|
||||||
|
if (bytes < 1_024) {
|
||||||
|
return "$bytes ${stringResource(R.string.unit_bytes)}"
|
||||||
|
}
|
||||||
|
if (bytes < 1_024 * 1_024) {
|
||||||
|
val kb = bytes / 1_024f
|
||||||
|
return String.format(Locale.getDefault(), "%.1f %s", kb, stringResource(R.string.unit_kilobytes))
|
||||||
|
}
|
||||||
|
val mb = bytes / (1_024f * 1_024f)
|
||||||
|
return String.format(Locale.getDefault(), "%.1f %s", mb, stringResource(R.string.unit_megabytes))
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
package com.vaultdrop.mobile.ui.folderdetail
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vaultdrop.mobile.auth.TokenProvider
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FileEntity
|
||||||
|
import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
||||||
|
import com.vaultdrop.mobile.data.repository.FileRepository
|
||||||
|
import com.vaultdrop.mobile.data.repository.FolderRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class FolderDetailUiState(
|
||||||
|
val folder: FolderEntity? = null,
|
||||||
|
val folderMissing: Boolean = false,
|
||||||
|
val subFolders: List<FolderEntity> = emptyList(),
|
||||||
|
val files: List<FileEntity> = emptyList(),
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class FolderDetailViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val folderRepository: FolderRepository,
|
||||||
|
private val fileRepository: FileRepository,
|
||||||
|
private val tokenProvider: TokenProvider,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val folderResourceId: String =
|
||||||
|
checkNotNull(savedStateHandle["folderResourceId"])
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(FolderDetailUiState())
|
||||||
|
val uiState: StateFlow<FolderDetailUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val folder = folderRepository.getFolder(folderResourceId)
|
||||||
|
if (folder == null) {
|
||||||
|
// Écran orphelin (dossier supprimé/réconcilié) → le composable
|
||||||
|
// navigate en arrière.
|
||||||
|
_uiState.value = _uiState.value.copy(folderMissing = true)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_uiState.value = _uiState.value.copy(folder = folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
folderRepository.observeSubFolders(folderResourceId).collect { subFolders ->
|
||||||
|
_uiState.update { it.copy(subFolders = subFolders) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
fileRepository.observeFiles(folderResourceId).collect { files ->
|
||||||
|
_uiState.update { it.copy(files = files) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Sans token → mode local : les fichiers n'existent que localement.
|
||||||
|
if (tokenProvider.current == null) return@launch
|
||||||
|
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
||||||
|
runCatching { fileRepository.refreshFromServer(folderResourceId) }
|
||||||
|
.onFailure { e ->
|
||||||
|
_uiState.update { it.copy(error = e.message ?: "Erreur réseau") }
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isRefreshing = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-2
@@ -40,6 +40,7 @@ import com.vaultdrop.mobile.data.local.entity.FolderEntity
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun FolderListScreen(
|
fun FolderListScreen(
|
||||||
|
onOpenFolder: (String) -> Unit,
|
||||||
viewModel: FolderListViewModel = hiltViewModel(),
|
viewModel: FolderListViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
@@ -58,6 +59,7 @@ fun FolderListScreen(
|
|||||||
) { padding ->
|
) { padding ->
|
||||||
FolderListContent(
|
FolderListContent(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
|
onOpenFolder = onOpenFolder,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -66,6 +68,7 @@ fun FolderListScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun FolderListContent(
|
private fun FolderListContent(
|
||||||
uiState: FolderListUiState,
|
uiState: FolderListUiState,
|
||||||
|
onOpenFolder: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
@@ -103,7 +106,7 @@ private fun FolderListContent(
|
|||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
items(uiState.folders, key = { it.resourceId }) { folder ->
|
items(uiState.folders, key = { it.resourceId }) { folder ->
|
||||||
FolderRow(folder)
|
FolderRow(folder, onClick = { onOpenFolder(folder.resourceId) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,8 +114,9 @@ private fun FolderListContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FolderRow(folder: FolderEntity) {
|
private fun FolderRow(folder: FolderEntity, onClick: () -> Unit) {
|
||||||
Card(
|
Card(
|
||||||
|
onClick = onClick,
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
package com.vaultdrop.mobile.ui.navigation
|
package com.vaultdrop.mobile.ui.navigation
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.vaultdrop.mobile.ui.folderdetail.FolderDetailScreen
|
||||||
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
|
import com.vaultdrop.mobile.ui.folderlist.FolderListScreen
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
const val FILES = "files"
|
const val FILES = "files"
|
||||||
|
const val FOLDER_DETAIL = "folder/{folderResourceId}"
|
||||||
|
const val ARG_FOLDER = "folderResourceId"
|
||||||
|
|
||||||
|
fun folder(folderResourceId: String): String = "folder/$folderResourceId"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -18,7 +25,24 @@ fun NavGraph() {
|
|||||||
startDestination = Routes.FILES,
|
startDestination = Routes.FILES,
|
||||||
) {
|
) {
|
||||||
composable(Routes.FILES) {
|
composable(Routes.FILES) {
|
||||||
FolderListScreen()
|
FolderListScreen(
|
||||||
|
onOpenFolder = { id -> navController.navigate(Routes.folder(id)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.FOLDER_DETAIL,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(Routes.ARG_FOLDER) { type = NavType.StringType },
|
||||||
|
),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val folderId = checkNotNull(
|
||||||
|
backStackEntry.arguments?.getString(Routes.ARG_FOLDER),
|
||||||
|
)
|
||||||
|
FolderDetailScreen(
|
||||||
|
folderResourceId = folderId,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onOpenFolder = { id -> navController.navigate(Routes.folder(id)) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,8 +7,12 @@
|
|||||||
<string name="no_folders_yet">No folders yet. Tap "Add a folder" to sync a folder.</string>
|
<string name="no_folders_yet">No folders yet. Tap "Add a folder" to sync a folder.</string>
|
||||||
<string name="no_files_yet">No files yet. Tap "Add a folder" to sync your files.</string>
|
<string name="no_files_yet">No files yet. Tap "Add a folder" to sync your files.</string>
|
||||||
<string name="empty_folder">Empty folder — next sync will refresh it.</string>
|
<string name="empty_folder">Empty folder — next sync will refresh it.</string>
|
||||||
|
<string name="unit_bytes">B</string>
|
||||||
|
<string name="unit_kilobytes">KB</string>
|
||||||
|
<string name="unit_megabytes">MB</string>
|
||||||
<string name="error_loading">Could not load folders</string>
|
<string name="error_loading">Could not load folders</string>
|
||||||
<string name="refresh">Refresh</string>
|
<string name="refresh">Refresh</string>
|
||||||
|
<string name="back">Back</string>
|
||||||
|
|
||||||
<!-- Login -->
|
<!-- Login -->
|
||||||
<string name="login_title">Sign in</string>
|
<string name="login_title">Sign in</string>
|
||||||
|
|||||||
@@ -8,8 +8,12 @@
|
|||||||
<string name="no_folders_yet">Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier.</string>
|
<string name="no_folders_yet">Aucun dossier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser un dossier.</string>
|
||||||
<string name="no_files_yet">Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers.</string>
|
<string name="no_files_yet">Aucun fichier pour le moment. Appuie sur « Ajouter un dossier » pour synchroniser tes fichiers.</string>
|
||||||
<string name="empty_folder">Dossier vide — le prochain syncDevice l\'actualisera.</string>
|
<string name="empty_folder">Dossier vide — le prochain syncDevice l\'actualisera.</string>
|
||||||
|
<string name="unit_bytes">o</string>
|
||||||
|
<string name="unit_kilobytes">ko</string>
|
||||||
|
<string name="unit_megabytes">Mo</string>
|
||||||
<string name="error_loading">Impossible de charger les dossiers</string>
|
<string name="error_loading">Impossible de charger les dossiers</string>
|
||||||
<string name="refresh">Actualiser</string>
|
<string name="refresh">Actualiser</string>
|
||||||
|
<string name="back">Retour</string>
|
||||||
|
|
||||||
<!-- Login -->
|
<!-- Login -->
|
||||||
<string name="login_title">Connexion</string>
|
<string name="login_title">Connexion</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user