("route")}")
58 | println("noteData: ${noteData.noteId}")
59 |
60 | noteData.noteId?.let { noteId ->
61 | println("get noteId: $noteId")
62 | viewModelScope.launch {
63 | localDataSource.getNoteById(noteId)?.let { note ->
64 | editedNote = note
65 | state =
66 | state.copy(
67 | title = TextFieldState(note.title),
68 | content = TextFieldState(note.content ?: ""),
69 | category = note.category?.toString()
70 | )
71 | }
72 | }
73 |
74 | voiceRecorderLocalDataSource.voiceRecordingsFlow
75 | .onEach { voiceRecordings ->
76 | state =
77 | state.copy(
78 | voiceRecordings = voiceRecordings.filter { it.noteId == noteId }
79 | )
80 | }.launchIn(viewModelScope)
81 | }
82 |
83 | canSaveNote
84 | .onEach {
85 | state = state.copy(canSaveNote = it.isNotEmpty())
86 | }.launchIn(viewModelScope)
87 | }
88 |
89 | fun saveAudioNoteToDatabase(filePath: String) {
90 | updateRecordedFilePath(null)
91 | editedNote?.id?.let { noteId ->
92 | viewModelScope.launch {
93 | val voiceRecorder =
94 | VoiceRecorder(
95 | id = UUID.randomUUID().toString(),
96 | noteId = noteId,
97 | name = "Note Voice",
98 | path = filePath,
99 | transcription = null
100 | )
101 | voiceRecorderLocalDataSource.addVoiceRecorder(voiceRecorder)
102 | eventChannel.send(NoteCreateEvent.SaveVoiceRecorder)
103 | }
104 | }
105 | }
106 |
107 | fun onAction(action: ActionNoteCreate) {
108 | viewModelScope.launch {
109 | when (action) {
110 | is ActionNoteCreate.ChangeNoteCategory -> {
111 | state = state.copy(category = action.category.toString())
112 | }
113 |
114 | is ActionNoteCreate.SaveNote -> {
115 | editedNote?.let {
116 | this@NoteCreateViewModel.localDataSource.updateNote(
117 | updatedNote =
118 | it.copy(
119 | id = it.id,
120 | title = state.title.text.toString(),
121 | content = state.content.text.toString(),
122 | category = state.category
123 | )
124 | )
125 | } ?: run {
126 | state.noteId?.let {
127 | val note =
128 | Note(
129 | id = it,
130 | title = state.title.text.toString(),
131 | content = state.content.text.toString(),
132 | category = state.category
133 | )
134 | localDataSource.addNote(
135 | note = note
136 | )
137 | }
138 | }
139 | eventChannel.send(NoteCreateEvent.NoteCreated)
140 | }
141 |
142 | is ActionNoteCreate.SaveVoiceRecorder -> {
143 | val updatedRecordings =
144 | state.voiceRecordings.map { record ->
145 | if (record.id == action.recordId) {
146 | val updatedRecord =
147 | record.copy(transcription = action.transcription)
148 | voiceRecorderLocalDataSource.updateVoiceRecorder(updatedRecord)
149 | updatedRecord
150 | } else {
151 | record
152 | }
153 | }
154 |
155 | state = state.copy(voiceRecordings = updatedRecordings)
156 |
157 | eventChannel.send(NoteCreateEvent.TranscriptionUpdate)
158 | }
159 |
160 | else -> Unit
161 | }
162 | }
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Note.AI
2 |
3 |
4 |
5 | 
6 |
7 |
8 |
9 |
10 |
11 |
12 | 🤖 Note.AI, proyecto android desarrollado con Jetpack Compose, Hilt, Coroutines, Flow, Jetpack (Room, ViewModel), Material3, arquitectura MVVM.
13 |
14 |
15 |
16 |
17 |
18 |
19 | ## Note.AI
20 | Es un proyecto personal que he estado compartiendo mis avances en mis redes sociales, he seguido el desarrollo moderno en android.
21 |
22 | - **Notas:** Listado y creación de notas, guardadas en base de datos local.
23 | - **Grabación de voz:** Notas de voz asociadas a las notas creadas.
24 | - **Open.AI:** Transcripción de audio a texto usando el modelo Whisper.
25 | - **Configuración Centralizada:** Administra la configuración y claves de API con BuildKConfig.
26 | - **Interfaz Moderna:** Construida con Kotlin Compose para una experiencia visual atractiva y responsiva.
27 |
28 | ## 🛠️ Instalación y Configuración
29 |
30 | ##### 1️⃣ Clonar el Repositorio
31 | ```bash
32 | git clone https://github.com/gonzalo-droid/NoteAI.git
33 | ```
34 | ##### 2️⃣ Generar tu Clave de API KEY en Open.AI Platform
35 | - Visita https://platform.openai.com/docs/overview
36 | - Regístrate o inicia sesión.
37 | - Dirígete a la sección API de tu cuenta y genera una nueva clave de API
38 | - Recuerda que para el correcto funcionamiento debes agregar unos cuantos dolares para realizar las pruebas
39 | ##### 3️⃣ Agregar la Clave de API en local.properties
40 | - En la raíz del proyecto, crea (o actualiza) un archivo llamado local.properties y agrega la siguiente línea:
41 | ```bash
42 | API_KEY_OPENAI=TU_CLAVE_DE_API_AQUÍ
43 | ```
44 | ##### 4️⃣ Compilar y Ejecutar el Proyecto
45 | - Usa Gradle para compilar y ejecutar el proyecto:
46 | ```bash
47 | ./gradlew run
48 | ```
49 | Para Android, abre el proyecto en Android Studio y ejecuta la aplicación desde allí. 📱🚀
50 |
51 |
52 | ## Tech stack
53 |
54 | - **SDK mínimo:** 26.
55 | - Basado en [Kotlin](https://kotlinlang.org/), utilizando [Coroutines](https://github.com/Kotlin/kotlinx.coroutines) + [Flow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/) para operaciones asíncronas.
56 |
57 | - **Jetpack Compose:** Kit de herramientas moderno de Android para desarrollo de UI declarativa.
58 | - **Lifecycle:** Observa los ciclos de vida de Android y gestiona los estados de UI ante cambios de ciclo de vida.
59 | - **ViewModel:** Administra datos relacionados con la UI y es consciente del ciclo de vida, asegurando la persistencia de datos tras cambios de configuración.
60 | - **Navigation:** Facilita la navegación entre pantallas, complementado con [Hilt Navigation Compose](https://developer.android.com/jetpack/compose/libraries#hilt) para inyección de dependencias.
61 | - **Room:** Permite construir una base de datos con una capa de abstracción sobre SQLite para un acceso eficiente a los datos.
62 | - **[Hilt](https://dagger.dev/hilt/):** Simplifica la inyección de dependencias en la aplicación.
63 | - **Arquitectura MVVM (View - ViewModel - Model):** Promueve la separación de responsabilidades y mejora el mantenimiento del código.
64 | - **Patrón Repository:** Actúa como mediador entre diferentes fuentes de datos y la lógica de negocio de la aplicación.
65 | - **[Kotlin Serialization](https://github.com/Kotlin/kotlinx.serialization):** Serialización sin reflejos para múltiples plataformas y formatos en Kotlin.
66 | - **[ksp](https://github.com/google/ksp):** API de procesamiento de símbolos en Kotlin para generación y análisis de código.
67 | - **[ktlint](https://github.com/pinterest/ktlint):** Linter para kotlin. https://medium.com/@amitdogra70512/ktlint-integration-in-android-952049b9d17d
68 | ## Architecture
69 | **Note.AI** sigue la aquitectura MVVM e implementa patrón Repository, alineado con [Guía oficl de arquitectura de Google](https://developer.android.com/topic/architecture).
70 |
71 | La arquitectura de **Note.AI** está estructurada en dos capas distintas: la capa de UI y la capa de datos. Cada capa cumple roles y responsabilidades específicas, que se describen a continuación.
72 |
73 | **Note.AI** sigue los principios establecidos en la [Guía de arquitectura de aplicaciones](https://developer.android.com/topic/architecture), lo que lo convierte en un excelente ejemplo de la aplicación práctica de conceptos arquitectónicos.
74 |
75 |
76 | ### Architecture Overview
77 |
78 | - Cada capa sigue los principios de [flujo unidireccional de eventos/datos](https://developer.android.com/topic/architecture/ui-layer#udf): la capa de UI envía eventos del usuario a la capa de datos, y la capa de datos proporciona flujos de datos a otras capas.
79 | - La capa de datos opera de forma independiente de las demás capas, manteniendo su pureza sin depender de capas externas.
80 |
81 | ✅ Esta arquitectura desacoplada mejora la reutilización de componentes y la escalabilidad de la aplicación, facilitando su desarrollo y mantenimiento.
82 |
83 |
84 | #### 🎨 Capa de UI
85 |
86 | La capa de UI abarca los elementos visuales responsables de configurar las pantallas para la interacción del usuario, junto con el [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel), que gestiona los estados de la aplicación y restaura los datos durante los cambios de configuración.
87 |
88 | 📌 **Principales características:**
89 | - Los elementos de UI observan el flujo de datos, asegurando la sincronización con la capa de datos subyacente.
90 | - Se encarga de manejar las interacciones del usuario y reflejar los cambios en la interfaz de manera eficiente.
91 |
92 | #### 📂 Capa de Datos
93 |
94 | La capa de datos está compuesta por repositorios que manejan la lógica de negocio, como la recuperación de datos desde una base de datos local o la obtención de datos remotos desde la red. Esta capa está diseñada para priorizar el acceso sin conexión, funcionando principalmente como un repositorio *offline-first* de la lógica de negocio.
95 |
96 | 📌 **Principales características:**
97 | - Sigue el principio de **"fuente única de la verdad"** (*single source of truth*), garantizando que todas las operaciones de datos sean centralizadas y consistentes.
98 | - Gestiona la persistencia de datos y la comunicación con fuentes externas.
99 |
100 | ## Open API
101 | Note.AI usa open.ai para la transcripción de las notas de voz a texto.
102 |
103 | ### Sigamos en contacto
104 |
105 | ✨ **Espero que este proyecto te sea útil para seguir aprendiendo.**
106 | 💡 ¡Puedes colaborar en mejoras del proyecto dejando un *Pull Request*!
107 | ⭐ Además, agradecería mucho que le dieras una estrella al proyecto 🤩
108 |
109 |
110 | Aún estoy definiendo el formato 🫠, pero lo importante es empezar.
111 | ¡Suscríbete y vamos a codear!
112 | - [YouTube](https://www.youtube.com/@gonzalolock)
113 | - [TikTok](https://www.tiktok.com/@gonzalock.dev)
114 | - [LinkedIn](https://www.linkedin.com/in/gonzalo-lozg/)
115 |
116 |
117 | ## 🚀 ¡Contribuciones bienvenidas!
118 |
119 | 💡 **Si quieres proponer mejoras o corregir errores:**
120 | 1. Haz un *fork* del repositorio.
121 | 2. Crea una rama con tu mejora.
122 | ```bash
123 | git checkout -b feature/your-feature-name
124 | ```
125 | 3. Realiza los cambios y haz un *commit*.
126 | ```bash
127 | git commit -am 'Add some feature'
128 | ```
129 | 4. Sube los cambios a tu repositorio.
130 | ```bash
131 | git push origin feature/your-feature-name
132 | ```
133 | 5. Abre un *Pull Request* para revisión.
134 |
135 |
136 | ## License
137 |
138 | This project is licensed under the **MIT License**. See the [LICENSE](LICENSE) file for details.
139 |
--------------------------------------------------------------------------------
/app/src/main/java/com/gondroid/noteai/presentation/screens/components/AudioPlayerItem.kt:
--------------------------------------------------------------------------------
1 | package com.gondroid.noteai.presentation.screens.components
2 |
3 | import android.media.MediaPlayer
4 | import android.net.Uri
5 | import android.util.Log
6 | import androidx.compose.animation.AnimatedVisibility
7 | import androidx.compose.foundation.background
8 | import androidx.compose.foundation.border
9 | import androidx.compose.foundation.layout.Arrangement
10 | import androidx.compose.foundation.layout.Box
11 | import androidx.compose.foundation.layout.Column
12 | import androidx.compose.foundation.layout.Row
13 | import androidx.compose.foundation.layout.Spacer
14 | import androidx.compose.foundation.layout.fillMaxWidth
15 | import androidx.compose.foundation.layout.height
16 | import androidx.compose.foundation.layout.padding
17 | import androidx.compose.foundation.layout.width
18 | import androidx.compose.foundation.shape.RoundedCornerShape
19 | import androidx.compose.material.icons.Icons
20 | import androidx.compose.material.icons.filled.KeyboardArrowDown
21 | import androidx.compose.material.icons.filled.KeyboardArrowUp
22 | import androidx.compose.material.icons.filled.MusicNote
23 | import androidx.compose.material.icons.filled.Pause
24 | import androidx.compose.material.icons.filled.PlayArrow
25 | import androidx.compose.material.icons.filled.Textsms
26 | import androidx.compose.material3.Button
27 | import androidx.compose.material3.ButtonDefaults
28 | import androidx.compose.material3.CircularProgressIndicator
29 | import androidx.compose.material3.Icon
30 | import androidx.compose.material3.IconButton
31 | import androidx.compose.material3.MaterialTheme
32 | import androidx.compose.material3.Slider
33 | import androidx.compose.material3.Text
34 | import androidx.compose.runtime.Composable
35 | import androidx.compose.runtime.DisposableEffect
36 | import androidx.compose.runtime.LaunchedEffect
37 | import androidx.compose.runtime.getValue
38 | import androidx.compose.runtime.mutableFloatStateOf
39 | import androidx.compose.runtime.mutableStateOf
40 | import androidx.compose.runtime.remember
41 | import androidx.compose.runtime.setValue
42 | import androidx.compose.ui.Alignment
43 | import androidx.compose.ui.Modifier
44 | import androidx.compose.ui.draw.clip
45 | import androidx.compose.ui.graphics.Color
46 | import androidx.compose.ui.platform.LocalContext
47 | import androidx.compose.ui.text.font.FontWeight
48 | import androidx.compose.ui.tooling.preview.Preview
49 | import androidx.compose.ui.unit.dp
50 | import androidx.compose.ui.window.Popup
51 | import com.gondroid.noteai.ui.theme.NoteAppTheme
52 | import kotlinx.coroutines.delay
53 |
54 | @Composable
55 | fun AudioPlayerItemRoot(
56 | modifier: Modifier,
57 | audioName: String,
58 | transcription: String?,
59 | date: String,
60 | audioUri: Uri,
61 | onTranscribe: () -> Unit
62 | ) {
63 | val context = LocalContext.current
64 | val mediaPlayer = remember { MediaPlayer() }
65 | var isPlaying by remember { mutableStateOf(false) }
66 | var progress by remember { mutableFloatStateOf(0f) }
67 | var duration by remember { mutableFloatStateOf(1f) }
68 |
69 | DisposableEffect(Unit) {
70 | try {
71 | mediaPlayer.reset()
72 | mediaPlayer.setDataSource(context, audioUri)
73 | mediaPlayer.prepare()
74 | duration = mediaPlayer.duration.toFloat()
75 | } catch (e: Exception) {
76 | Log.d("ErrorAudio", e.message.toString())
77 | e.printStackTrace()
78 | }
79 |
80 | mediaPlayer.setOnCompletionListener {
81 | isPlaying = false
82 | progress = 0f
83 | }
84 |
85 | onDispose {
86 | mediaPlayer.release()
87 | }
88 | }
89 |
90 | LaunchedEffect(isPlaying) {
91 | while (isPlaying) {
92 | progress = mediaPlayer.currentPosition.toFloat()
93 | delay(500) // Actualiza cada 500ms
94 | }
95 | }
96 |
97 | AudioPlayerItem(
98 | modifier = modifier,
99 | progress = progress,
100 | duration = duration,
101 | audioName = audioName,
102 | transcription = transcription,
103 | date = date,
104 | onTranscribe = onTranscribe,
105 | isPlaying = isPlaying,
106 | onTogglePlay = {
107 | if (isPlaying) {
108 | mediaPlayer.pause()
109 | } else {
110 | mediaPlayer.start()
111 | }
112 | isPlaying = !isPlaying
113 | }
114 | )
115 | }
116 |
117 | @Composable
118 | fun AudioPlayerItem(
119 | modifier: Modifier,
120 | audioName: String,
121 | transcription: String?,
122 | date: String,
123 | onTranscribe: () -> Unit,
124 | progress: Float,
125 | duration: Float,
126 | isPlaying: Boolean,
127 | onTogglePlay: () -> Unit
128 | ) {
129 | var isOpen by remember { mutableStateOf(true) }
130 | var isLoading by remember { mutableStateOf(false) }
131 |
132 | Column {
133 | Row(
134 | modifier =
135 | modifier
136 | .clip(RoundedCornerShape(8.dp))
137 | .background(MaterialTheme.colorScheme.inversePrimary)
138 | .padding(16.dp),
139 | verticalAlignment = Alignment.CenterVertically
140 | ) {
141 | Icon(imageVector = Icons.Default.MusicNote, contentDescription = "Audio Icon")
142 | Spacer(modifier = Modifier.width(8.dp))
143 | Column(modifier = Modifier.weight(1f)) {
144 | Text(text = audioName, fontWeight = FontWeight.Bold)
145 | Text(text = date, style = MaterialTheme.typography.bodySmall)
146 | Slider(
147 | value = progress,
148 | onValueChange = {},
149 | valueRange = 0f..duration,
150 | modifier = Modifier.fillMaxWidth()
151 | )
152 | }
153 |
154 | IconButton(onClick = { onTogglePlay() }) {
155 | Icon(
156 | imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
157 | contentDescription = "Play/Pause"
158 | )
159 | }
160 |
161 | if (!isLoading) {
162 | IconButton(onClick = {
163 | onTranscribe()
164 | isLoading = true
165 | }) {
166 | Icon(imageVector = Icons.Default.Textsms, contentDescription = "More Options")
167 | }
168 | } else {
169 | CircularProgressIndicator(
170 | modifier =
171 | Modifier
172 | .padding(0.dp)
173 | .align(Alignment.CenterVertically)
174 | .width(30.dp)
175 | .height(30.dp),
176 | color = MaterialTheme.colorScheme.secondary,
177 | trackColor = MaterialTheme.colorScheme.surfaceVariant
178 | )
179 | }
180 | }
181 |
182 | transcription?.let {
183 | isLoading = false
184 | Column(
185 | modifier =
186 | Modifier
187 | .fillMaxWidth()
188 | .padding(top = 2.dp)
189 | .clip(RoundedCornerShape(8.dp))
190 | .background(MaterialTheme.colorScheme.tertiary),
191 | verticalArrangement = Arrangement.Top
192 | ) {
193 | Button(
194 | onClick = { isOpen = !isOpen },
195 | modifier =
196 | Modifier
197 | .fillMaxWidth(),
198 | shape =
199 | RoundedCornerShape(
200 | topStart = 8.dp,
201 | topEnd = 8.dp,
202 | bottomEnd = 0.dp,
203 | bottomStart = 0.dp
204 | ),
205 | colors =
206 | ButtonDefaults.buttonColors(
207 | containerColor = MaterialTheme.colorScheme.tertiary
208 | )
209 | ) {
210 | Text(
211 | text = "Trancripción"
212 | )
213 | Icon(
214 | imageVector = if (isOpen) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
215 | contentDescription = null
216 | )
217 | }
218 |
219 | AnimatedVisibility(visible = isOpen) {
220 | Box(
221 | modifier =
222 | Modifier
223 | .fillMaxWidth()
224 | .background(Color.White)
225 | .border(
226 | width = 1.dp,
227 | color = MaterialTheme.colorScheme.tertiary,
228 | shape =
229 | RoundedCornerShape(
230 | topStart = 0.dp,
231 | topEnd = 0.dp,
232 | bottomEnd = 8.dp,
233 | bottomStart = 8.dp
234 | )
235 | )
236 | ) {
237 | Text(
238 | text = it,
239 | modifier =
240 | Modifier
241 | .padding(8.dp)
242 | )
243 | }
244 | }
245 | }
246 | }
247 | }
248 | }
249 |
250 | @Composable
251 | fun CustomTooltipExample() {
252 | var showTooltip by remember { mutableStateOf(false) }
253 |
254 | Box(modifier = Modifier.padding(16.dp)) {
255 | Button(onClick = { showTooltip = !showTooltip }) {
256 | Text("Show Tooltip")
257 | }
258 |
259 | if (showTooltip) {
260 | Popup {
261 | Box(
262 | modifier =
263 | Modifier
264 | .background(
265 | Color.Black.copy(alpha = 0.8f),
266 | shape = RoundedCornerShape(8.dp)
267 | ).padding(8.dp)
268 | ) {
269 | Text("Custom Tooltip", color = Color.White)
270 | }
271 | }
272 | }
273 | }
274 | }
275 |
276 | @Composable
277 | @Preview(
278 | showBackground = true
279 | )
280 | fun AudioPlayerItemDark() {
281 | NoteAppTheme {
282 | AudioPlayerItem(
283 | modifier = Modifier,
284 | audioName = "Audio Name",
285 | date = "Date",
286 | onTranscribe = {},
287 | progress = 1f,
288 | duration = 3f,
289 | onTogglePlay = {},
290 | isPlaying = false,
291 | transcription = "transcription on real time"
292 | )
293 | }
294 | }
295 |
--------------------------------------------------------------------------------
/app/src/main/java/com/gondroid/noteai/presentation/screens/taskCreate/TaskCreateScreen.kt:
--------------------------------------------------------------------------------
1 | package com.gondroid.noteai.presentation.screens.taskCreate
2 |
3 | import android.content.res.Configuration.UI_MODE_NIGHT_YES
4 | import android.widget.Toast
5 | import androidx.compose.foundation.clickable
6 | import androidx.compose.foundation.layout.Arrangement
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Row
9 | import androidx.compose.foundation.layout.Spacer
10 | import androidx.compose.foundation.layout.fillMaxSize
11 | import androidx.compose.foundation.layout.fillMaxWidth
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.layout.wrapContentHeight
14 | import androidx.compose.foundation.text.BasicTextField
15 | import androidx.compose.foundation.text.input.TextFieldLineLimits
16 | import androidx.compose.material.icons.Icons
17 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
18 | import androidx.compose.material3.Button
19 | import androidx.compose.material3.Checkbox
20 | import androidx.compose.material3.ExperimentalMaterial3Api
21 | import androidx.compose.material3.Icon
22 | import androidx.compose.material3.MaterialTheme
23 | import androidx.compose.material3.Scaffold
24 | import androidx.compose.material3.Text
25 | import androidx.compose.material3.TopAppBar
26 | import androidx.compose.runtime.Composable
27 | import androidx.compose.runtime.LaunchedEffect
28 | import androidx.compose.runtime.getValue
29 | import androidx.compose.runtime.mutableStateOf
30 | import androidx.compose.runtime.remember
31 | import androidx.compose.runtime.setValue
32 | import androidx.compose.ui.Alignment
33 | import androidx.compose.ui.Modifier
34 | import androidx.compose.ui.focus.onFocusChanged
35 | import androidx.compose.ui.graphics.SolidColor
36 | import androidx.compose.ui.platform.LocalContext
37 | import androidx.compose.ui.res.stringResource
38 | import androidx.compose.ui.text.font.FontWeight
39 | import androidx.compose.ui.tooling.preview.Preview
40 | import androidx.compose.ui.tooling.preview.PreviewParameter
41 | import androidx.compose.ui.unit.dp
42 | import com.gondroid.noteai.R
43 | import com.gondroid.noteai.presentation.screens.taskCreate.providers.TaskCreateScreenStatePreviewProvider
44 | import com.gondroid.noteai.ui.theme.NoteAppTheme
45 |
46 | @Composable
47 | fun TaskCreateScreenRoot(
48 | navigateBack: () -> Boolean,
49 | viewModel: TaskCreateViewModel
50 | ) {
51 | val state = viewModel.state
52 | val event = viewModel.event
53 |
54 | val context = LocalContext.current
55 |
56 | LaunchedEffect(true) {
57 | event.collect { event ->
58 | when (event) {
59 | is TaskCreateEvent.TaskCreated -> {
60 | Toast
61 | .makeText(
62 | context,
63 | context.getString(R.string.task_created),
64 | Toast.LENGTH_SHORT
65 | ).show()
66 | navigateBack()
67 | }
68 | }
69 | }
70 | }
71 |
72 | TaskCreateScreen(
73 | state = state,
74 | onAction = { action ->
75 | when (action) {
76 | is ActionTask.Back -> {
77 | navigateBack()
78 | }
79 | else -> {
80 | viewModel.onAction(action)
81 | }
82 | }
83 | }
84 | )
85 | }
86 |
87 | @OptIn(ExperimentalMaterial3Api::class)
88 | @Composable
89 | fun TaskCreateScreen(
90 | modifier: Modifier = Modifier,
91 | state: TaskCreateScreenState,
92 | onAction: (ActionTask) -> Unit
93 | ) {
94 | var isDescriptionFocus by remember {
95 | mutableStateOf(false)
96 | }
97 | var isExpanded by remember {
98 | mutableStateOf(false)
99 | }
100 |
101 | Scaffold(
102 | topBar = {
103 | TopAppBar(
104 | title = {
105 | Text(
106 | style = MaterialTheme.typography.headlineSmall,
107 | text = stringResource(R.string.task)
108 | )
109 | },
110 | navigationIcon = {
111 | Icon(
112 | imageVector = Icons.AutoMirrored.Filled.ArrowBack,
113 | contentDescription = "Back",
114 | tint = MaterialTheme.colorScheme.onBackground,
115 | modifier =
116 | Modifier.clickable {
117 | onAction(
118 | ActionTask.Back
119 | )
120 | }
121 | )
122 | }
123 | )
124 | }
125 | ) { paddingValues ->
126 |
127 | Column(
128 | verticalArrangement = Arrangement.spacedBy(8.dp),
129 | modifier =
130 | Modifier
131 | .fillMaxSize()
132 | .padding(paddingValues)
133 | .padding(16.dp)
134 | ) {
135 | Row(
136 | verticalAlignment = Alignment.CenterVertically
137 | ) {
138 | Spacer(
139 | modifier = Modifier.weight(1f)
140 | )
141 | Text(
142 | text = stringResource(R.string.done),
143 | style =
144 | MaterialTheme.typography.bodyMedium.copy(
145 | color = MaterialTheme.colorScheme.onSurface
146 | ),
147 | modifier = Modifier.padding(8.dp)
148 | )
149 |
150 | Checkbox(
151 | checked = state.isTaskDone,
152 | onCheckedChange = {
153 | onAction(
154 | ActionTask.ChangeTaskDone(
155 | isTaskDone = it
156 | )
157 | )
158 | }
159 | )
160 | }
161 |
162 | BasicTextField(
163 | state = state.taskName,
164 | textStyle =
165 | MaterialTheme.typography.headlineLarge.copy(
166 | color = MaterialTheme.colorScheme.onSurface,
167 | fontWeight = FontWeight.Bold
168 | ),
169 | cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
170 | lineLimits = TextFieldLineLimits.SingleLine,
171 | modifier =
172 | Modifier
173 | .fillMaxWidth()
174 | .wrapContentHeight(),
175 | decorator = { innerTextField ->
176 | Column(
177 | modifier = Modifier.fillMaxWidth()
178 | ) {
179 | if ((
180 | state.taskName.text
181 | .toString()
182 | .isEmpty()
183 | )
184 | ) {
185 | Text(
186 | modifier = Modifier.fillMaxWidth(),
187 | text = stringResource(R.string.title),
188 | color =
189 | MaterialTheme.colorScheme.onSurface.copy(
190 | alpha = 0.5f
191 | ),
192 | style =
193 | MaterialTheme.typography.headlineLarge.copy(
194 | fontWeight = FontWeight.Bold
195 | )
196 | )
197 | } else {
198 | innerTextField()
199 | }
200 | }
201 | }
202 | )
203 |
204 | BasicTextField(
205 | state = state.taskDescription,
206 | cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
207 | textStyle =
208 | MaterialTheme.typography.bodyLarge.copy(
209 | color = MaterialTheme.colorScheme.onSurface
210 | ),
211 | lineLimits =
212 | if (isDescriptionFocus) {
213 | TextFieldLineLimits.MultiLine(
214 | minHeightInLines = 1,
215 | maxHeightInLines = 5
216 | )
217 | } else {
218 | TextFieldLineLimits.Default
219 | },
220 | modifier =
221 | Modifier
222 | .fillMaxWidth()
223 | .wrapContentHeight()
224 | .onFocusChanged {
225 | isDescriptionFocus = it.isFocused
226 | },
227 | decorator = { innerTextField ->
228 | Column {
229 | if (state.taskDescription.text
230 | .toString()
231 | .isEmpty() &&
232 | !isDescriptionFocus
233 | ) {
234 | Text(
235 | text = stringResource(R.string.task_description),
236 | color =
237 | MaterialTheme.colorScheme.onSurface.copy(
238 | alpha = 0.5f
239 | )
240 | )
241 | } else {
242 | innerTextField()
243 | }
244 | }
245 | }
246 | )
247 |
248 | Spacer(
249 | modifier = Modifier.weight(1f)
250 | )
251 |
252 | Button(
253 | enabled = state.canSaveTask,
254 | onClick = {
255 | onAction(
256 | ActionTask.SaveTask
257 | )
258 | },
259 | modifier =
260 | Modifier
261 | .fillMaxWidth()
262 | ) {
263 | Text(
264 | text = stringResource(R.string.save),
265 | style = MaterialTheme.typography.titleMedium,
266 | color =
267 | if (state.canSaveTask) {
268 | MaterialTheme.colorScheme.onPrimary
269 | } else {
270 | MaterialTheme.colorScheme.onPrimaryContainer
271 | }
272 | )
273 | }
274 | }
275 | }
276 | }
277 |
278 | @Composable
279 | @Preview
280 | fun TaskCreateScreenLightPreview(
281 | @PreviewParameter(TaskCreateScreenStatePreviewProvider::class) state: TaskCreateScreenState
282 | ) {
283 | NoteAppTheme {
284 | TaskCreateScreen(
285 | state = state,
286 | onAction = {}
287 | )
288 | }
289 | }
290 |
291 | @Composable
292 | @Preview(
293 | uiMode = UI_MODE_NIGHT_YES
294 | )
295 | fun TaskCreateScreenDarkPreview(
296 | @PreviewParameter(TaskCreateScreenStatePreviewProvider::class) state: TaskCreateScreenState
297 | ) {
298 | NoteAppTheme {
299 | TaskCreateScreen(
300 | state = state,
301 | onAction = {}
302 | )
303 | }
304 | }
305 |
--------------------------------------------------------------------------------