ContextualMenu

This commit is contained in:
dotmg 2025-07-08 11:58:49 +02:00
parent 82f0624095
commit e261be48f0
19 changed files with 159 additions and 125 deletions

View file

@ -29,6 +29,7 @@ kotlin {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.koin.android) // Koin Android-specific extensions
implementation(libs.koin.androidx.compose)
}
commonMain.dependencies {
@ -48,6 +49,11 @@ kotlin {
implementation(libs.serialization)
api(libs.kmp.observableviewmodel.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.material3)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
implementation(libs.koin.compose.viewmodel.navigation)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
@ -56,7 +62,6 @@ kotlin {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
}
//androidMain.dependsOn(androidAndJvmMain)
}
}

View file

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".AndroidApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"

View file

@ -8,7 +8,7 @@ import org.koin.android.ext.koin.androidLogger
import org.koin.core.logger.Level
import mg.dot.feufaro.di.androidModule
class AndroidApp: Application {
class AndroidApp: Application() {
override fun onCreate() {
super.onCreate()
startKoin {

View file

@ -1,46 +0,0 @@
package mg.dot.feufaro
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.File
import java.io.IOException
import java.io.InputStreamReader
// This is just a regular class that implements the common 'FileRepository' interface.
// It is NOT an 'actual' declaration of 'FileRepository'.
class AndroidFileRepository(private val context: Context) : FileRepository { // IMPORTS AND IMPLEMENTS THE commonMain 'FileRepository' interface
override suspend fun readFileLines(filePath: String): List<String> = withContext(Dispatchers.IO) {
try {
when {
filePath.startsWith("assets://") -> {
readAssetFileLines(filePath)
}
else -> {
File(filePath).readLines()
}
}
} catch (e: IOException) {
throw IOException("Failed to read file or asset '$filePath'")
}
}
override suspend fun readFileContent(filePath: String): String = withContext(Dispatchers.IO) { "" }
private fun readAssetFileLines(assetFileName: String): List<String> {
return context.assets.open(assetFileName).use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).useLines { it.toList() }
}
}
private fun readAssetFileContent(assetFileName: String): String {
return context.assets.open(assetFileName).use { inputStream ->
// BufferedReader(InputStreamReader(inputStream)) permet de lire le texte ligne par ligne.
BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText() // Lit tout le contenu en une seule chaîne.
}
}
}
}

View file

@ -6,6 +6,7 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import org.koin.androidx.compose.koinViewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@ -13,7 +14,8 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
setContent {
App()
val sharedViewModel: SharedViewModel = koinViewModel()
App(sharedViewModel = sharedViewModel)
}
}
}

View file

@ -1,7 +1,6 @@
package mg.dot.feufaro.di
import android.content.Context
import mg.dot.feufaro.AndroidFileRepository // Import the actual Android implementation
import mg.dot.feufaro.config.AppConfig
import org.koin.core.module.dsl.singleOf // Import for Koin DSL
import org.koin.core.module.dsl.bind // Import for Koin DSL
@ -12,15 +11,10 @@ import org.koin.android.ext.koin.androidContext // Import for androidContext()
val androidModule = module {
// Koin will automatically provide the Android Context because you called androidContext() in AndroidApp.
// It then uses this Context to construct AndroidFileRepository.
singleOf(::AndroidFileRepository) { bind<FileRepository>() }
single<AppConfig> {
AppConfig(
transposeto = "C",
transposeasif = "C",
buttonContainerColorHex = "#ff00f0",
buttonContentColorHex = "#ffffff",
buttonDisabledContainerColorHex = "#999999",
buttonDisabledContentColorHex = "#ffffff",
)
}

View file

@ -3,6 +3,7 @@ package mg.dot.feufaro
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
@ -26,6 +27,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -41,7 +43,10 @@ import kotlinx.coroutines.launch
import mg.dot.feufaro.solfa.LazyVerticalGridTUO
import mg.dot.feufaro.solfa.Solfa
import org.koin.compose.koinInject
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.window.Popup
import kotlin.math.roundToInt
@Composable
@Preview
@ -53,6 +58,8 @@ fun App(sharedViewModel: SharedViewModel = SharedViewModel()
val currentDisplayConfig by displayConfigManager.displayConfig.collectAsState()
// Load Configurations
val configScope = CoroutineScope(Dispatchers.Default)
var showContextualMenu by remember { mutableStateOf(false)}
var menuPosition by remember { mutableStateOf(Offset.Zero)}
LaunchedEffect(Unit) {
configScope.launch {
@ -82,7 +89,34 @@ fun App(sharedViewModel: SharedViewModel = SharedViewModel()
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
var gridWidthPx by remember { mutableStateOf(0) }
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
Column(
Modifier.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
offset ->
menuPosition = offset
showContextualMenu = true
}
)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (showContextualMenu) {
Popup(
alignment = Alignment.TopStart,
offset = IntOffset(menuPosition.x.roundToInt(),
menuPosition.y.roundToInt()),
onDismissRequest = {
showContextualMenu = false
}
) {
ContextualMenu(onMenuItemClick = { item ->
println("Clicked: $item")
showContextualMenu = false
})
}
}
Column(
modifier = Modifier
.fillMaxWidth()

View file

@ -0,0 +1,40 @@
package mg.dot.feufaro
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.material3.IconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.ui.unit.TextUnitType
@Composable
fun ContextualMenu (onMenuItemClick: (String) -> Unit){
Column(
modifier = Modifier
.background(Color.DarkGray.copy(alpha = 0.8f), RoundedCornerShape(8.dp))
.padding(8.dp)
) {
MenuItem(icon = Icons.Default.Add, text = "+") { onMenuItemClick("Ajouter")}
MenuItem(icon = Icons.Default.Edit, text = "!") { onMenuItemClick("Modifier")}
MenuItem(icon = Icons.Default.List, text = "-") { onMenuItemClick("Liste")}
}
}
@Composable
fun MenuItem(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String, onClick: () -> Unit) {
IconButton(onClick = onClick) {
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
Icon(icon, contentDescription = text, tint = Color.White)
}
}
}

View file

@ -1,13 +1,50 @@
package mg.dot.feufaro
import java.io.IOException // Java.io.IOException est généralement partagée sur JVM/Android
import feufaro.composeapp.generated.resources.Res
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
// Définissez une expect interface. Elle spécifie le contrat de votre repository.
// Utilisez 'expect interface' car l'implémentation (actual) variera selon la plateforme.
expect interface FileRepository {
interface FileRepository {
// Lecture de fichier ligne par ligne
suspend fun readFileLines(filePath: String): List<String>
// Lecture de fichier entier en tant que String
suspend fun readFileContent(filePath: String): String
}
// This is just a regular class that implements the common 'FileRepository' interface.
// It is NOT an 'actual' declaration of 'FileRepository'.
class CommonFileRepository : FileRepository { // IMPORTS AND IMPLEMENTS THE commonMain 'FileRepository' interface
override suspend fun readFileLines(filePath: String): List<String> = withContext(Dispatchers.IO) {
try {
when {
filePath.startsWith("assets://") -> {
readAssetFileLines(filePath)
}
else -> {
File(filePath).readLines()
}
}
} catch (e: IOException) {
throw IOException("Failed to read file or asset '$filePath'")
}
}
override suspend fun readFileContent(filePath: String): String = withContext(Dispatchers.IO) {
val lines = readFileLines(filePath)
lines.joinToString("\n") { it }
}
private suspend fun readAssetFileLines(assetFileName: String): List<String> {
return try {
Res.readBytes("files/"+assetFileName.removePrefix("assets://")).decodeToString().split("\n")
} catch (e: IOException) {
println("Could not read /"+assetFileName.removePrefix("assets://"))
throw IOException("Could not read asset file: $assetFileName", e)
}
}
}

View file

@ -29,6 +29,8 @@ open class SharedViewModel(): ViewModel() {
val tuoList: StateFlow<List<TimeUnitObject>> = _tuoList.asStateFlow()
private var _playlist = MutableStateFlow<List<String>>(viewModelScope, emptyList())
val playlist: StateFlow<List<String>> = _playlist.asStateFlow()
private var _nextPlayed = MutableStateFlow(viewModelScope, -1)
val nextPlayed: StateFlow<Int> = _nextPlayed.asStateFlow()
private val tempTimeUnitObjectList = mutableListOf<TimeUnitObject>()
var _hasMarker = MutableStateFlow<Boolean>(viewModelScope, false)
val hasMarker: StateFlow<Boolean> = _hasMarker.asStateFlow()
@ -83,4 +85,12 @@ open class SharedViewModel(): ViewModel() {
_hasMarker.value = theHasMarker
}
fun playNext() {
val nextIndex = (_nextPlayed.value + 1) % _playlist.value.size
_nextPlayed.value = nextIndex
}
fun currentPlayed(): String {
val playlistIndex = _nextPlayed.value
return _playlist.value[playlistIndex]
}
}

View file

@ -1,18 +1,22 @@
package mg.dot.feufaro.di
import mg.dot.feufaro.CommonFileRepository
import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.SharedViewModel
import mg.dot.feufaro.DisplayConfigManager // Importez DisplayConfigManager
import mg.dot.feufaro.config.AppConfig
import mg.dot.feufaro.musicXML.MusicXML
import org.koin.dsl.module
import org.koin.core.module.dsl.singleOf
import org.koin.core.module.Module
import org.koin.core.module.dsl.viewModel
val commonModule = module {
// Déclarez FileRepository comme un singleton.
// L'implémentation concrète (actual) sera résolue par Koin en fonction de la plateforme.
// Pour Android, Koin injectera le Context que vous avez fourni via androidContext().
singleOf(::MusicXML)
single<FileRepository> { CommonFileRepository() }
single { DisplayConfigManager(fileRepository = get())}
viewModel { SharedViewModel() }
}

View file

@ -36,7 +36,6 @@ class Solfa(val sharedViewModel: SharedViewModel, private val fileRepository: Fi
var nextLIndex: Int = -1
var inGroup: Boolean = false
var templateString: String = ""
var nextPlayed = -1
private val meta: MutableMap<String, String> = mutableMapOf()
private val lyricsComment: MutableList<String> = mutableListOf()
@ -94,15 +93,16 @@ class Solfa(val sharedViewModel: SharedViewModel, private val fileRepository: Fi
}
nextTimeUnitObject()
}
fun loadSolfa(sourceFileName: String) {
fun loadSolfa() {
val sourceFileName: String = sharedViewModel.currentPlayed()
sharedViewModel.reset()
parse(sourceFileName)
}
fun loadNextInPlaylist() {
val playlist = sharedViewModel?.playlist?.value ?: listOf()
val playlist = sharedViewModel.playlist.value
if (playlist.isNotEmpty()) {
nextPlayed = (nextPlayed + 1) % playlist.size
loadSolfa(playlist[nextPlayed])
sharedViewModel.playNext()
loadSolfa()
}
}
fun parse(sourceFile: String) {

View file

@ -6,9 +6,9 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Text
import androidx.compose.ui.Modifier
import androidx.compose.material3.Text
import androidx.compose.material3.LocalTextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.LaunchedEffect

View file

@ -1,41 +0,0 @@
package mg.dot.feufaro
import feufaro.composeapp.generated.resources.Res
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
// This is just a regular class that implements the common 'FileRepository' interface.
// It is NOT an 'actual' declaration of 'FileRepository'.
class DesktopFileRepository : FileRepository { // IMPORTS AND IMPLEMENTS THE commonMain 'FileRepository' interface
override suspend fun readFileLines(filePath: String): List<String> = withContext(Dispatchers.IO) {
try {
when {
filePath.startsWith("assets://") -> {
readAssetFileLines(filePath)
}
else -> {
File(filePath).readLines()
}
}
} catch (e: IOException) {
throw IOException("Failed to read file or asset '$filePath'")
}
}
override suspend fun readFileContent(filePath: String): String = withContext(Dispatchers.IO) {
val lines = readFileLines(filePath)
lines.joinToString("\n") { it }
}
private suspend fun readAssetFileLines(assetFileName: String): List<String> {
return try {
Res.readBytes("files/"+assetFileName.removePrefix("assets://")).decodeToString().split("\n")
} catch (e: IOException) {
println("Could not read /"+assetFileName.removePrefix("assets://"))
throw IOException("Could not read asset file: $assetFileName", e)
}
}
}

View file

@ -1,11 +0,0 @@
package mg.dot.feufaro
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import mg.dot.feufaro.FileRepository
actual interface FileRepository {
actual suspend fun readFileLines(filePath: String): List<String>
actual suspend fun readFileContent(filePath: String): String
}

View file

@ -1,6 +1,5 @@
package mg.dot.feufaro.di
import mg.dot.feufaro.DesktopFileRepository // Import the actual desktop implementation
import org.koin.core.module.dsl.singleOf // Import for Koin DSL
import org.koin.core.module.dsl.bind // Import for Koin DSL
import org.koin.dsl.module
@ -10,11 +9,10 @@ import mg.dot.feufaro.config.AppConfig
val desktopModule = module {
// When Koin is initialized on Desktop, it will use DesktopFileRepository
// as the implementation for the FileRepository interface.
singleOf(::DesktopFileRepository) { bind<FileRepository>() }
single<AppConfig> {
AppConfig(
transposeto = "C",
transposeasif = "C"
transposeasif = "C"
)
}
}

View file

@ -9,6 +9,7 @@ import org.koin.core.context.KoinContext
import org.koin.core.logger.Level
import org.koin.compose.KoinContext
fun main() = application {
startKoin {
printLogger(Level.INFO) // Mettez Level.INFO pour voir les logs de Koin
@ -24,7 +25,8 @@ fun main() = application {
title = "Feufaro",
) {
KoinContext {
App()
val sharedViewModel: SharedViewModel = SharedViewModel()
App(sharedViewModel = sharedViewModel)
}
}
}

View file

@ -1,5 +1,5 @@
[versions]
agp = "8.7.3"
agp = "8.10.1"
android-compileSdk = "35"
android-minSdk = "24"
android-targetSdk = "35"
@ -20,8 +20,11 @@ koin = "4.0.4"
core = "0.91.1"
kmpObservableviewmodelCore = "1.0.0-BETA-3"
kotlinxSerializationJson = "1.8.1"
material3 = "1.3.2"
[libraries]
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
koin-compose-viewmodel-navigation = { module = "io.insert-koin:koin-compose-viewmodel-navigation", version.ref = "koin" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
@ -39,13 +42,15 @@ koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
#20250704
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
core = { module = "io.github.pdvrieze.xmlutil:core", version.ref = "core" }
#core-android = { module = "io.github.pdvrieze.xmlutil:core-android", version.ref = "core" }
#core-jdk = { module = "io.github.pdvrieze.xmlutil:core-jdk", version.ref = "core" }
serialization = { module = "io.github.pdvrieze.xmlutil:serialization", version.ref = "core" }
kmp-observableviewmodel-core = { module = "com.rickclephas.kmp:kmp-observableviewmodel-core", version.ref = "kmpObservableviewmodelCore" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
androidx-material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME