Compare commits

...

2 Commits

Author SHA1 Message Date
c9d05be4b1 Merge pull request 'test' (#7) from changes into master
Reviewed-on: #7
2026-03-09 20:26:02 -04:00
mac
73a7f46509 test 2026-03-09 20:25:10 -04:00
9 changed files with 231 additions and 123 deletions

View File

@@ -18,7 +18,6 @@ android {
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
// Incrémentation automatique du versionCode basé sur le temps
versionCode = (System.currentTimeMillis() / 60000).toInt() versionCode = (System.currentTimeMillis() / 60000).toInt()
versionName = "1.0." + (System.currentTimeMillis() / 3600000).toString().takeLast(3) versionName = "1.0." + (System.currentTimeMillis() / 3600000).toString().takeLast(3)
@@ -26,7 +25,6 @@ android {
} }
signingConfigs { signingConfigs {
// Chargement des propriétés depuis local.properties
val keystoreProperties = Properties() val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("local.properties") val keystorePropertiesFile = rootProject.file("local.properties")
if (keystorePropertiesFile.exists()) { if (keystorePropertiesFile.exists()) {
@@ -35,9 +33,9 @@ android {
getByName("debug") { getByName("debug") {
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore") storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
getByName("debug").storePassword = "android" storePassword = "android"
getByName("debug").keyAlias = "androiddebugkey" keyAlias = "androiddebugkey"
getByName("debug").keyPassword = "android" keyPassword = "android"
} }
create("release") { create("release") {
@@ -53,7 +51,7 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true // Activer l'offuscation isMinifyEnabled = true
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -111,9 +109,7 @@ dependencies {
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material.icons.extended)
// SDK Firebase App Distribution COMPLET (API + Implémentation)
implementation(libs.firebase.appdistribution) implementation(libs.firebase.appdistribution)
implementation(libs.google.generativeai) implementation(libs.google.generativeai)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.androidx.exifinterface) implementation(libs.androidx.exifinterface)
@@ -133,17 +129,13 @@ dependencies {
implementation(libs.firebase.firestore) implementation(libs.firebase.firestore)
implementation(libs.firebase.appcheck.playintegrity) implementation(libs.firebase.appcheck.playintegrity)
// Barcode Scanning & Camera
implementation(libs.mlkit.barcode.scanning) implementation(libs.mlkit.barcode.scanning)
implementation(libs.androidx.camera.core) implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view) implementation(libs.androidx.camera.view)
// PDF generation
implementation(libs.itext7.core) implementation(libs.itext7.core)
// Security
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
testImplementation(libs.junit) testImplementation(libs.junit)

View File

@@ -100,24 +100,28 @@ fun AuthWrapper(dao: AppDao) {
if (email != null && email.isNotEmpty()) { if (email != null && email.isNotEmpty()) {
Log.d("Auth", "Vérification de l'autorisation pour l'email: '$email'") Log.d("Auth", "Vérification de l'autorisation pour l'email: '$email'")
try { try {
// On spécifie explicitement la base de données "scan-wich"
val db = FirebaseFirestore.getInstance("scan-wich") val db = FirebaseFirestore.getInstance("scan-wich")
val docRef = db.collection("authorized_users").document(email) val docRef = db.collection("authorized_users").document(email)
val document = docRef.get().await() val document = docRef.get().await()
if (document.exists()) { if (document.exists()) {
Log.d("Auth", "Accès AUTORISÉ pour '$email'. Document trouvé.") Log.d("Auth", "Accès AUTORISÉ pour '$email'.")
// --- NOUVEAU : Rapatriement des données ---
coroutineScope.launch(Dispatchers.IO) {
FirebaseUtils.fetchAllDataFromFirestore(dao)
}
isAuthorized = true isAuthorized = true
} else { } else {
Log.w("Auth", "Accès REFUSÉ pour '$email'. Document NON trouvé.") Log.w("Auth", "Accès REFUSÉ pour '$email'.")
isAuthorized = false isAuthorized = false
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Auth", "Erreur critique Firestore. Vérifiez les règles de sécurité.", e) Log.e("Auth", "Erreur critique Firestore.", e)
isAuthorized = false isAuthorized = false
} }
} else if (firebaseUser != null) { } else if (firebaseUser != null) {
Log.w("Auth", "L'utilisateur est connecté mais son email est vide.")
isAuthorized = false isAuthorized = false
} }
} }
@@ -132,7 +136,7 @@ fun AuthWrapper(dao: AppDao) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text("Vérification de l'accès...") Text("Vérification et synchronisation...")
} }
} }
} }

View File

@@ -51,21 +51,29 @@ data class FavoriteMeal(
@Dao @Dao
interface AppDao { interface AppDao {
@Insert suspend fun insertMeal(meal: Meal): Long // On ajoute OnConflictStrategy.REPLACE pour la synchronisation
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMeal(meal: Meal): Long
@Delete suspend fun deleteMeal(meal: Meal) @Delete suspend fun deleteMeal(meal: Meal)
@Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC") @Query("SELECT * FROM meals WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>> fun getMealsForDay(startOfDay: Long, endOfDay: Long): Flow<List<Meal>>
@Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC") @Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC")
suspend fun getMealsInRangeSync(start: Long, end: Long): List<Meal> suspend fun getMealsInRangeSync(start: Long, end: Long): List<Meal>
@Insert suspend fun insertGlycemia(glycemia: Glycemia): Long // On ajoute OnConflictStrategy.REPLACE pour la synchronisation
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGlycemia(glycemia: Glycemia): Long
@Delete suspend fun deleteGlycemia(glycemia: Glycemia) @Delete suspend fun deleteGlycemia(glycemia: Glycemia)
@Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC") @Query("SELECT * FROM glycemia WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>> fun getGlycemiaForDay(startOfDay: Long, endOfDay: Long): Flow<List<Glycemia>>
@Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC") @Query("SELECT * FROM glycemia WHERE date >= :start AND date <= :end ORDER BY date ASC")
suspend fun getGlycemiaInRangeSync(start: Long, end: Long): List<Glycemia> suspend fun getGlycemiaInRangeSync(start: Long, end: Long): List<Glycemia>
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSports(sports: List<SportActivity>) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertSports(sports: List<SportActivity>)
@Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow<List<SportActivity>> @Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow<List<SportActivity>>
@Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC") @Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC")
fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>> fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow<List<SportActivity>>

View File

@@ -4,14 +4,13 @@ import android.util.Log
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.tasks.await
object FirebaseUtils { object FirebaseUtils {
private fun getDb(): FirebaseFirestore { private fun getDb(): FirebaseFirestore {
return try { return try {
// Tente d'utiliser la base de données nommée "scan-wich"
FirebaseFirestore.getInstance("scan-wich") FirebaseFirestore.getInstance("scan-wich")
} catch (e: Exception) { } catch (e: Exception) {
// Repli sur la base par défaut si "scan-wich" n'est pas configurée comme base secondaire
FirebaseFirestore.getInstance() FirebaseFirestore.getInstance()
} }
} }
@@ -48,4 +47,34 @@ object FirebaseUtils {
.addOnFailureListener { e -> Log.e("Firestore", "Error sport: ${e.message}") } .addOnFailureListener { e -> Log.e("Firestore", "Error sport: ${e.message}") }
} }
} }
// NOUVELLE FONCTION : Rapatrie toutes les données depuis le Cloud
suspend fun fetchAllDataFromFirestore(dao: AppDao) {
val user = FirebaseAuth.getInstance().currentUser ?: return
val db = getDb()
val userDoc = db.collection("users").document(user.uid)
try {
// 1. Récupérer les repas
val mealsSnapshot = userDoc.collection("meals").get().await()
val meals = mealsSnapshot.toObjects(Meal::class.java)
meals.forEach { dao.insertMeal(it) }
Log.d("FirestoreSync", "${meals.size} repas récupérés")
// 2. Récupérer la glycémie
val glycemiaSnapshot = userDoc.collection("glycemia").get().await()
val glycemia = glycemiaSnapshot.toObjects(Glycemia::class.java)
glycemia.forEach { dao.insertGlycemia(it) }
Log.d("FirestoreSync", "${glycemia.size} glycémies récupérées")
// 3. Récupérer le sport
val sportsSnapshot = userDoc.collection("sports").get().await()
val sports = sportsSnapshot.toObjects(SportActivity::class.java)
dao.insertSports(sports)
Log.d("FirestoreSync", "${sports.size} activités sportives récupérées")
} catch (e: Exception) {
Log.e("FirestoreSync", "Erreur lors du rapatriement des données: ${e.message}")
}
}
} }

View File

@@ -12,6 +12,7 @@ import com.google.firebase.Firebase
import com.google.firebase.appcheck.appCheck import com.google.firebase.appcheck.appCheck
import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory
import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory
import com.google.firebase.functions.functions
import com.google.firebase.initialize import com.google.firebase.initialize
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -58,20 +59,34 @@ class MainActivity : ComponentActivity() {
val code = data.getQueryParameter("code") val code = data.getQueryParameter("code")
if (code != null) { if (code != null) {
val prefs = ApiClient.getEncryptedPrefs(this) val prefs = ApiClient.getEncryptedPrefs(this)
val clientId = prefs.getString("strava_client_id", "") ?: "" val functions = Firebase.functions
val clientSecret = prefs.getString("strava_client_secret", "") ?: ""
if (clientId.isNotEmpty() && clientSecret.isNotEmpty()) { val requestData = hashMapOf(
CoroutineScope(Dispatchers.IO).launch { "code" to code
try { )
val response = ApiClient.stravaApi.exchangeToken(clientId, clientSecret, code)
prefs.edit { functions.getHttpsCallable("exchangeStravaToken")
putString("strava_token", response.accessToken) .call(requestData)
putString("strava_refresh_token", response.refreshToken) .addOnSuccessListener { result ->
putLong("strava_expires_at", response.expiresAt) val res = result.data as? Map<*, *>
if (res != null) {
val accessToken = res["access_token"] as? String
val refreshToken = res["refresh_token"] as? String
val expiresAt = (res["expires_at"] as? Number)?.toLong() ?: 0L
if (accessToken != null && refreshToken != null) {
prefs.edit {
putString("strava_token", accessToken)
putString("strava_refresh_token", refreshToken)
putLong("strava_expires_at", expiresAt)
}
Log.d("StravaAuth", "Token exchange successful")
} }
} catch (e: Exception) { Log.e("StravaAuth", "Exchange failed: ${e.message}") } }
}
.addOnFailureListener { e ->
Log.e("StravaAuth", "Cloud Function exchange failed: ${e.message}")
} }
}
} }
} }
} }

View File

@@ -1,10 +1,16 @@
package com.example.scanwich package com.example.scanwich
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.core.content.edit import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey import androidx.security.crypto.MasterKey
import com.google.firebase.Firebase
import com.google.firebase.functions.functions
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -13,6 +19,7 @@ import retrofit2.http.*
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlinx.coroutines.tasks.await
// --- OPEN FOOD FACTS API --- // --- OPEN FOOD FACTS API ---
data class OffProductResponse(val status: Int, val product: OffProduct?) data class OffProductResponse(val status: Int, val product: OffProduct?)
@@ -64,12 +71,15 @@ object ApiClient {
.create(StravaApi::class.java) .create(StravaApi::class.java)
val offApi: OffApi = Retrofit.Builder() val offApi: OffApi = Retrofit.Builder()
.baseUrl("https://world.openfoodfacts.org/api/v2/") // On force l'utilisation de l'API française pour les codes-barres
.baseUrl("https://fr.openfoodfacts.org/api/v2/")
.client(okHttpClient) .client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
.create(OffApi::class.java) .create(OffApi::class.java)
private const val STRAVA_CLIENT_ID = "203805"
fun getEncryptedPrefs(context: Context): SharedPreferences { fun getEncryptedPrefs(context: Context): SharedPreferences {
val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
return EncryptedSharedPreferences.create( return EncryptedSharedPreferences.create(
@@ -84,19 +94,38 @@ object ApiClient {
suspend fun getValidStravaToken(prefs: SharedPreferences): String? { suspend fun getValidStravaToken(prefs: SharedPreferences): String? {
val stravaToken = prefs.getString("strava_token", null) ?: return null val stravaToken = prefs.getString("strava_token", null) ?: return null
val expiresAt = prefs.getLong("strava_expires_at", 0) val expiresAt = prefs.getLong("strava_expires_at", 0)
if (System.currentTimeMillis() / 1000 >= expiresAt) {
if (System.currentTimeMillis() / 1000 >= (expiresAt - 300)) {
val refreshToken = prefs.getString("strava_refresh_token", null) ?: return null val refreshToken = prefs.getString("strava_refresh_token", null) ?: return null
val clientId = prefs.getString("strava_client_id", "") ?: ""
val clientSecret = prefs.getString("strava_client_secret", "") ?: ""
return try { return try {
val res = stravaApi.refreshToken(clientId, clientSecret, refreshToken) val functions = Firebase.functions
prefs.edit { val data = hashMapOf("refreshToken" to refreshToken)
putString("strava_token", res.accessToken)
putString("strava_refresh_token", res.refreshToken) val result = functions.getHttpsCallable("refreshStravaToken")
putLong("strava_expires_at", res.expiresAt) .call(data)
.await()
val res = result.data as? Map<*, *>
if (res != null) {
val newAccessToken = res["access_token"] as? String
val newRefreshToken = res["refresh_token"] as? String
val newExpiresAt = (res["expires_at"] as? Number)?.toLong() ?: 0L
if (newAccessToken != null && newRefreshToken != null) {
prefs.edit {
putString("strava_token", newAccessToken)
putString("strava_refresh_token", newRefreshToken)
putLong("strava_expires_at", newExpiresAt)
}
return newAccessToken
}
} }
res.accessToken null
} catch (e: Exception) { null } } catch (e: Exception) {
Log.e("StravaAuth", "Refresh failed: ${e.message}")
null
}
} }
return stravaToken return stravaToken
} }
@@ -125,4 +154,18 @@ object ApiClient {
val durationHours = activity.movingTime / 3600.0 val durationHours = activity.movingTime / 3600.0
return met * weightKg * durationHours return met * weightKg * durationHours
} }
fun launchStravaAuth(context: Context) {
val intentUri = Uri.parse("https://www.strava.com/oauth/mobile/authorize")
.buildUpon()
.appendQueryParameter("client_id", STRAVA_CLIENT_ID)
.appendQueryParameter("redirect_uri", "coloricam://localhost")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("approval_prompt", "auto")
.appendQueryParameter("scope", "activity:read_all")
.build()
val intent = Intent(Intent.ACTION_VIEW, intentUri)
context.startActivity(intent)
}
} }

View File

@@ -1,8 +1,6 @@
package com.example.scanwich package com.example.scanwich
import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -16,16 +14,27 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri
@Composable @Composable
fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) { fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpdated: () -> Unit) {
var isEditing by remember { mutableStateOf(false) } var isEditing by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
var stravaClientId by remember { mutableStateOf(prefs.getString("strava_client_id", "") ?: "") } // État réactif pour la connexion Strava
var stravaClientSecret by remember { mutableStateOf(prefs.getString("strava_client_secret", "") ?: "") } var isStravaConnected by remember { mutableStateOf(prefs.contains("strava_token")) }
val isStravaConnected = prefs.contains("strava_token")
// Écouteur pour mettre à jour l'état si le token change
DisposableEffect(prefs) {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { p, key ->
if (key == "strava_token") {
isStravaConnected = p.contains("strava_token")
}
}
prefs.registerOnSharedPreferenceChangeListener(listener)
onDispose {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
}
if (isEditing) { if (isEditing) {
SetupScreen(prefs) { SetupScreen(prefs) {
@@ -48,51 +57,25 @@ fun SettingsScreen(prefs: SharedPreferences, onLogout: () -> Unit, onProfileUpda
Button(onClick = { isEditing = true }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Edit, null); Text(" Modifier le profil") } Button(onClick = { isEditing = true }, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Edit, null); Text(" Modifier le profil") }
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Text("Configuration Strava", style = MaterialTheme.typography.titleMedium) Text("Intégrations", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedTextField( if (isStravaConnected) {
value = stravaClientId,
onValueChange = { stravaClientId = it; prefs.edit { putString("strava_client_id", it) } },
label = { Text("Strava Client ID") },
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = stravaClientSecret,
onValueChange = { stravaClientSecret = it; prefs.edit { putString("strava_client_secret", it) } },
label = { Text("Strava Client Secret") },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
if (!isStravaConnected) {
Button(
onClick = {
if (stravaClientId.isBlank() || stravaClientSecret.isBlank()) {
Toast.makeText(context, "Entrez votre ID et Secret Client", Toast.LENGTH_SHORT).show()
} else {
val intent = Intent(Intent.ACTION_VIEW, "https://www.strava.com/oauth/mobile/authorize?client_id=$stravaClientId&redirect_uri=coloricam://localhost&response_type=code&approval_prompt=auto&scope=read,activity:read_all".toUri())
context.startActivity(intent)
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Connecter Strava")
}
} else {
OutlinedButton( OutlinedButton(
onClick = { onClick = {
prefs.edit { prefs.edit {
remove("strava_token") remove("strava_token")
remove("strava_refresh_token") remove("strava_refresh_token")
} }
// L'écouteur mettra `isStravaConnected` à jour automatiquement
Toast.makeText(context, "Strava déconnecté", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Strava déconnecté", Toast.LENGTH_SHORT).show()
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text("Déconnecter Strava (Connecté)") Text("Déconnecter Strava (Connecté)")
} }
} else {
Text("Aucune intégration active. Allez dans l'onglet 'Sport' pour connecter Strava.", style = MaterialTheme.typography.bodyMedium)
} }
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))

View File

@@ -1,6 +1,7 @@
package com.example.scanwich package com.example.scanwich
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -8,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -23,21 +25,51 @@ import java.text.SimpleDateFormat
import java.util.* import java.util.*
@Composable @Composable
fun SportScreen(dao: AppDao, prefs: android.content.SharedPreferences) { fun SportScreen(dao: AppDao, prefs: SharedPreferences) {
val sports by dao.getAllSports().collectAsState(initial = emptyList()) val sports by dao.getAllSports().collectAsState(initial = emptyList())
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
// État réactif pour la connexion Strava
var isConnectedToStrava by remember {
mutableStateOf(prefs.getString("strava_token", null) != null)
}
// Écouteur de changements pour les SharedPreferences
DisposableEffect(prefs) {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { p, key ->
if (key == "strava_token") {
isConnectedToStrava = p.getString("strava_token", null) != null
}
}
prefs.registerOnSharedPreferenceChangeListener(listener)
onDispose {
prefs.unregisterOnSharedPreferenceChangeListener(listener)
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("Activités Sportives", style = MaterialTheme.typography.headlineMedium) Text("Activités Sportives", style = MaterialTheme.typography.headlineMedium)
Button( if (!isConnectedToStrava) {
onClick = { syncStravaActivities(dao, prefs, coroutineScope, context) }, Button(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp) onClick = { ApiClient.launchStravaAuth(context) },
) { modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
Icon(Icons.Default.Refresh, null) colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFC6100)) // Strava orange
Spacer(Modifier.width(8.dp)) ) {
Text("Synchroniser Strava") Icon(Icons.Default.Sync, null)
Spacer(Modifier.width(8.dp))
Text("Se connecter à Strava")
}
} else {
Button(
onClick = { syncStravaActivities(dao, prefs, coroutineScope, context) },
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
) {
Icon(Icons.Default.Refresh, null)
Spacer(Modifier.width(8.dp))
Text("Synchroniser les activités")
}
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -50,7 +82,8 @@ fun SportScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) Text(activity.name, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text(SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)), style = MaterialTheme.typography.bodySmall) Text(SimpleDateFormat("dd/MM HH:mm", Locale.getDefault()).format(Date(activity.date)), style = MaterialTheme.typography.bodySmall)
} }
Text("${activity.type} - ${(activity.distance / 1000).format(2)} km") val distanceInKm = activity.distance / 1000
Text("${activity.type} - ${String.format(Locale.getDefault(), "%.2f", distanceInKm)} km")
Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) Text("${activity.calories?.toInt() ?: 0} kcal brûlées", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
} }
} }
@@ -59,11 +92,11 @@ fun SportScreen(dao: AppDao, prefs: android.content.SharedPreferences) {
} }
} }
private fun syncStravaActivities(dao: AppDao, prefs: android.content.SharedPreferences, scope: CoroutineScope, context: Context) { private fun syncStravaActivities(dao: AppDao, prefs: SharedPreferences, scope: CoroutineScope, context: Context) {
scope.launch { scope.launch {
val token = ApiClient.getValidStravaToken(prefs) val token = ApiClient.getValidStravaToken(prefs)
if (token == null) { if (token == null) {
Toast.makeText(context, "Veuillez connecter Strava dans les paramètres", Toast.LENGTH_LONG).show() Toast.makeText(context, "Erreur de connexion Strava", Toast.LENGTH_LONG).show()
return@launch return@launch
} }
try { try {
@@ -83,7 +116,7 @@ private fun syncStravaActivities(dao: AppDao, prefs: android.content.SharedPrefe
).toFloat(), ).toFloat(),
date = ApiClient.parseStravaDate(it.startDate) date = ApiClient.parseStravaDate(it.startDate)
) )
syncSportToFirestore(activity) // Firestore Sync syncSportToFirestore(activity)
activity activity
} }
dao.insertSports(sportActivities) dao.insertSports(sportActivities)

View File

@@ -2,48 +2,49 @@
**Changements majeurs de la version actuelle :** **Changements majeurs de la version actuelle :**
🎨 **Améliorations de l'Interface Utilisateur :** 🇫🇷 **Expérience 100% en Français :**
- **Correction du Thème :** Résolution d'un problème de contraste sur l'écran de configuration du profil qui rendait le texte illisible (texte jaune sur fond blanc). L'écran respecte désormais correctement le thème de l'application. - **IA Francophone :** Les analyses de repas (caméra et manuel) sont désormais exclusivement en français grâce à des instructions renforcées côté serveur.
- **Scanner Localisé :** Utilisation forcée de la base de données française d'Open Food Facts pour le scan de codes-barres, garantissant des noms de produits familiers.
🛡️ **Refonte de l'Architecture de Sécurité et de Données :** 🤖 **Analyse IA plus Robuste :**
- **Gestion des accès centralisée :** L'ancien système d'utilisateurs autorisés codé en dur dans l'application a été supprimé. L'accès est désormais contrôlé de manière sécurisée et dynamique via une liste d'autorisation sur le serveur Firebase Firestore. Cela ouvre la voie à la gestion d'abonnements. - **Correctif d'analyse :** Résolution du bug "Erreur IA" lié au formatage des réponses du modèle.
- **Profils Utilisateurs dans le Cloud :** Les données de profil (poids, objectifs, etc.) sont maintenant synchronisées avec le compte Firebase de l'utilisateur, permettant une expérience cohérente sur plusieurs appareils. - **Fiabilité accrue :** Nettoyage automatique des données nutritionnelles côté serveur (Cloud Functions).
- **Correctif Critique de Connexion :** Résolution d'un bug majeur qui empêchait l'application de se connecter correctement à la base de données Firestore, causant des accès refusés inattendus pour les utilisateurs légitimes. L'application est désormais compatible avec les bases de données Firestore nommées.
🚀 **Connexion Strava 100% Automatique :**
- **Simplicité totale :** Connexion en un seul clic sans saisie d'identifiants techniques.
- **Sécurité Maximale :** Utilisation de Google Cloud Secret Manager pour la protection des clés API Strava.
🎨 **Améliorations UI/UX :**
- **Interface épurée :** Suppression des réglages superflus.
- **Correction du Thème :** Lisibilité parfaite sur l'écran de profil.
🛡️ **Architecture Cloud :**
- **Gestion des accès :** Contrôle dynamique des utilisateurs autorisés via Firestore.
- **Profils Cloud :** Synchronisation automatique de vos données personnelles.
--- ---
**Mises à jour précédentes :** **Mises à jour précédentes :**
🛠️ **Correctifs et Améliorations Strava :** 🛠️ **Correctifs et Améliorations Strava :**
- Résolution d'un problème de compilation bloquant sur l'écran des sports. - Résolution de bugs de compilation et amélioration du parsing des dates.
- Intégration d'un nouvel algorithme d'estimation des calories basé sur les MET (Metabolic Equivalent of Task) pour une précision accrue. - Nouvel algorithme d'estimation des calories basé sur les MET.
- Amélioration de la fiabilité du parsing des dates d'activités Strava.
🛡️ **Sécurité renforcée :** 🛡️ **Sécurité renforcée :**
- Intégration de Firebase App Check (Play Integrity) pour protéger l'API contre les accès non autorisés. - Intégration de Firebase App Check (Play Integrity).
- Migration de la clé API vers Google Cloud Secret Manager, supprimant toute information sensible du code source. - Migration des clés vers Secret Manager.
⚡ **Analyse Ultra-Rapide :** ⚡ **Analyse Ultra-Rapide :**
- Nouveau moteur de compression d'image intelligent (réduction de ~2.2 Mo à 150 Ko par scan), accélérant drastiquement l'analyse IA. - Nouveau moteur de compression d'image intelligent.
🤖 **IA Sécurisée :**
- Migration de la logique d'analyse (prompts) vers des Cloud Functions pour garantir des résultats plus fiables et protégés.
📄 **Export PDF Professionnel :** 📄 **Export PDF Professionnel :**
- Nouvelle fonctionnalité d'exportation de l'historique. Générez un rapport PDF complet incluant vos repas, activités sportives et suivis de glycémie. - Exportation de l'historique complet (repas, sport, glycémie).
🩸 **Suivi Diabétique complet :** 🩸 **Suivi Diabétique :**
- Possibilité d'enregistrer et de visualiser sa glycémie avant/après chaque catégorie de repas directement dans l'historique. - Visualisation de la glycémie directement dans l'historique.
🚴 **Synchronisation Strava :**
- Connexion directe à Strava pour importer vos activités et calculer précisément les calories brûlées.
⭐ **Gestion des Favoris :** ⭐ **Gestion des Favoris :**
- Refonte de l'interface des favoris avec une nouvelle fenêtre modale pour un ajout rapide de vos repas récurrents. - Nouvelle interface d'ajout rapide.
🔍 **Scanner de Code-barres :** 🔧 **Stabilité :**
- Intégration d'Open Food Facts pour identifier instantanément les produits industriels via leur code-barres. - Optimisation pour Android 36 et corrections diverses.
🔧 **Stabilité et Modernisation :**
- Optimisation pour Android 36.
- Correction de bugs majeurs sur le stockage sécurisé des préférences (MasterKey) et la synchronisation Firebase (collectAsState).