← Volver al listado de tecnologías
Proyecto Final - App de Notas
Descripción del Proyecto
Construiremos una app de notas completa que integra los conceptos aprendidos:
- Múltiples pantallas con navegación
- Persistencia de datos
- KivyMD para UI moderna
- CRUD completo (Crear, Leer, Actualizar, Eliminar)
Estructura del Proyecto
notas_app/
├── main.py
├── app.kv
├── buildozer.spec
├── models/
│ ├── __init__.py
│ └── nota.py
├── screens/
│ ├── __init__.py
│ ├── lista.py
│ ├── editor.py
│ └── detalle.py
├── services/
│ ├── __init__.py
│ └── storage.py
└── assets/
└── icon.png
Modelo de Datos
# models/nota.py
from kivy.properties import StringProperty, NumericProperty
from kivy.event import EventDispatcher
from datetime import datetime
import uuid
class Nota(EventDispatcher):
id = StringProperty('')
titulo = StringProperty('')
contenido = StringProperty('')
fecha_creacion = StringProperty('')
fecha_modificacion = StringProperty('')
def __init__(self, **kwargs):
super().__init__(**kwargs)
if not self.id:
self.id = str(uuid.uuid4())
if not self.fecha_creacion:
self.fecha_creacion = datetime.now().isoformat()
self.fecha_modificacion = datetime.now().isoformat()
def actualizar(self, titulo, contenido):
self.titulo = titulo
self.contenido = contenido
self.fecha_modificacion = datetime.now().isoformat()
def to_dict(self):
return {
'id': self.id,
'titulo': self.titulo,
'contenido': self.contenido,
'fecha_creacion': self.fecha_creacion,
'fecha_modificacion': self.fecha_modificacion
}
@classmethod
def from_dict(cls, data):
return cls(**data)
Servicio de Almacenamiento
# services/storage.py
from kivy.storage.jsonstore import JsonStore
from kivy.app import App
from models.nota import Nota
class NotasStorage:
def __init__(self):
app = App.get_running_app()
ruta = f'{app.user_data_dir}/notas.json'
self.store = JsonStore(ruta)
def guardar_nota(self, nota):
self.store.put(nota.id, **nota.to_dict())
def obtener_nota(self, nota_id):
if self.store.exists(nota_id):
data = self.store.get(nota_id)
return Nota.from_dict(data)
return None
def obtener_todas(self):
notas = []
for key in self.store.keys():
data = self.store.get(key)
notas.append(Nota.from_dict(data))
# Ordenar por fecha de modificación
notas.sort(key=lambda n: n.fecha_modificacion, reverse=True)
return notas
def eliminar_nota(self, nota_id):
if self.store.exists(nota_id):
self.store.delete(nota_id)
def buscar(self, texto):
texto = texto.lower()
return [
n for n in self.obtener_todas()
if texto in n.titulo.lower() or texto in n.contenido.lower()
]
Pantalla Lista
# screens/lista.py
from kivy.uix.screenmanager import Screen
from kivy.properties import ObjectProperty
from kivy.app import App
class ListaScreen(Screen):
lista = ObjectProperty(None)
def on_enter(self):
self.cargar_notas()
def cargar_notas(self):
self.ids.lista.clear_widgets()
notas = App.get_running_app().storage.obtener_todas()
for nota in notas:
self.agregar_item_nota(nota)
def agregar_item_nota(self, nota):
from kivymd.uix.list import TwoLineListItem
item = TwoLineListItem(
text=nota.titulo or 'Sin título',
secondary_text=self.preview(nota.contenido),
on_release=lambda x, n=nota: self.abrir_nota(n)
)
self.ids.lista.add_widget(item)
def preview(self, texto, largo=50):
if len(texto) > largo:
return texto[:largo] + '...'
return texto
def abrir_nota(self, nota):
app = App.get_running_app()
app.nota_actual = nota
self.manager.current = 'detalle'
def nueva_nota(self):
app = App.get_running_app()
app.nota_actual = None
self.manager.current = 'editor'
def buscar(self, texto):
self.ids.lista.clear_widgets()
if texto:
notas = App.get_running_app().storage.buscar(texto)
else:
notas = App.get_running_app().storage.obtener_todas()
for nota in notas:
self.agregar_item_nota(nota)
Pantalla Editor
# screens/editor.py
from kivy.uix.screenmanager import Screen
from kivy.app import App
from models.nota import Nota
class EditorScreen(Screen):
def on_enter(self):
app = App.get_running_app()
if app.nota_actual:
self.ids.titulo.text = app.nota_actual.titulo
self.ids.contenido.text = app.nota_actual.contenido
else:
self.ids.titulo.text = ''
self.ids.contenido.text = ''
def guardar(self):
app = App.get_running_app()
titulo = self.ids.titulo.text.strip()
contenido = self.ids.contenido.text.strip()
if not titulo and not contenido:
return
if app.nota_actual:
app.nota_actual.actualizar(titulo, contenido)
nota = app.nota_actual
else:
nota = Nota(titulo=titulo, contenido=contenido)
app.storage.guardar_nota(nota)
self.manager.current = 'lista'
def cancelar(self):
self.manager.current = 'lista'
Pantalla Detalle
# screens/detalle.py
from kivy.uix.screenmanager import Screen
from kivy.app import App
from kivymd.uix.dialog import MDDialog
from kivymd.uix.button import MDFlatButton
class DetalleScreen(Screen):
dialog = None
def on_enter(self):
app = App.get_running_app()
nota = app.nota_actual
if nota:
self.ids.titulo.text = nota.titulo or 'Sin título'
self.ids.contenido.text = nota.contenido
self.ids.fecha.text = self.formatear_fecha(nota.fecha_modificacion)
def formatear_fecha(self, fecha_iso):
from datetime import datetime
fecha = datetime.fromisoformat(fecha_iso)
return fecha.strftime('%d/%m/%Y %H:%M')
def editar(self):
self.manager.current = 'editor'
def confirmar_eliminar(self):
if not self.dialog:
self.dialog = MDDialog(
title='Eliminar nota',
text='¿Estás seguro de eliminar esta nota?',
buttons=[
MDFlatButton(
text='CANCELAR',
on_release=lambda x: self.dialog.dismiss()
),
MDFlatButton(
text='ELIMINAR',
on_release=lambda x: self.eliminar()
),
],
)
self.dialog.open()
def eliminar(self):
self.dialog.dismiss()
app = App.get_running_app()
app.storage.eliminar_nota(app.nota_actual.id)
app.nota_actual = None
self.manager.current = 'lista'
def volver(self):
self.manager.current = 'lista'
Archivo KV Principal
# app.kv
#:import NoTransition kivy.uix.screenmanager.NoTransition
MDScreenManager:
transition: NoTransition()
ListaScreen:
EditorScreen:
DetalleScreen:
<ListaScreen>:
name: 'lista'
MDBoxLayout:
orientation: 'vertical'
MDTopAppBar:
title: 'Mis Notas'
right_action_items: [['magnify', lambda x: None]]
MDTextField:
id: busqueda
hint_text: 'Buscar notas...'
mode: 'rectangle'
size_hint_x: 0.9
pos_hint: {'center_x': 0.5}
on_text: root.buscar(self.text)
ScrollView:
MDList:
id: lista
MDFloatingActionButton:
icon: 'plus'
pos_hint: {'center_x': 0.9, 'center_y': 0.1}
on_release: root.nueva_nota()
<EditorScreen>:
name: 'editor'
MDBoxLayout:
orientation: 'vertical'
MDTopAppBar:
title: 'Editar Nota' if app.nota_actual else 'Nueva Nota'
left_action_items: [['arrow-left', lambda x: root.cancelar()]]
right_action_items: [['check', lambda x: root.guardar()]]
MDTextField:
id: titulo
hint_text: 'Título'
mode: 'fill'
size_hint_y: None
height: '56dp'
MDTextField:
id: contenido
hint_text: 'Escribe tu nota aquí...'
mode: 'fill'
multiline: True
<DetalleScreen>:
name: 'detalle'
MDBoxLayout:
orientation: 'vertical'
MDTopAppBar:
title: 'Detalle'
left_action_items: [['arrow-left', lambda x: root.volver()]]
right_action_items:
[['pencil', lambda x: root.editar()], \
['delete', lambda x: root.confirmar_eliminar()]]
MDBoxLayout:
orientation: 'vertical'
padding: '16dp'
spacing: '8dp'
MDLabel:
id: titulo
font_style: 'H5'
size_hint_y: None
height: self.texture_size[1]
MDLabel:
id: fecha
font_style: 'Caption'
theme_text_color: 'Secondary'
size_hint_y: None
height: self.texture_size[1]
ScrollView:
MDLabel:
id: contenido
size_hint_y: None
height: self.texture_size[1]
text_size: self.width, None
Main.py
# main.py
from kivymd.app import MDApp
from kivy.properties import ObjectProperty
from services.storage import NotasStorage
class NotasApp(MDApp):
nota_actual = ObjectProperty(None, allownone=True)
storage = ObjectProperty(None)
def build(self):
self.theme_cls.primary_palette = 'Indigo'
self.theme_cls.theme_style = 'Light'
self.storage = NotasStorage()
def on_start(self):
pass
if __name__ == '__main__':
NotasApp().run()
buildozer.spec
[app]
title = Mis Notas
package.name = misnotas
package.domain = org.ejemplo
source.dir = .
source.include_exts = py,png,jpg,kv,json
version = 1.0.0
requirements = python3,kivy,kivymd
orientation = portrait
fullscreen = 0
android.permissions = WRITE_EXTERNAL_STORAGE
android.api = 33
android.minapi = 21
android.archs = arm64-v8a
icon.filename = assets/icon.png
Mejoras Sugeridas
- Agregar categorías/etiquetas a las notas
- Implementar sincronización en la nube
- Añadir búsqueda por voz
- Soporte para imágenes en notas
- Modo oscuro
- Exportar notas a PDF
- Recordatorios con notificaciones
Resumen del Curso
A lo largo de este tutorial aprendiste:
- Fundamentos: Instalación, estructura de apps, ciclo de vida
- UI: Widgets, layouts, lenguaje KV
- Interactividad: Propiedades, eventos, binding
- Gráficos: Canvas, animaciones, Clock
- Navegación: ScreenManager, transiciones
- Material Design: KivyMD para UI moderna
- Extensiones: Kivy Garden
- Producción: Buildozer, APIs nativas
- Juegos: Game loop, colisiones, sprites
- Arquitectura: Patrones, testing, optimización
Próximos Pasos
- Practica construyendo proyectos propios
- Contribuye a Kivy Garden
- Explora KivEnt para juegos complejos
- Publica tu primera app en Play Store
Testing del Proyecto Final
# test_notas.py
import unittest
from unittest.mock import MagicMock, patch
import tempfile
import os
# Importar modelos (ajustar según estructura)
# from models.nota import Nota
# from services.storage import NotasStorage
class TestNota(unittest.TestCase):
def test_crear_nota(self):
nota = {'titulo': 'Test', 'contenido': 'Contenido'}
self.assertEqual(nota['titulo'], 'Test')
def test_nota_tiene_campos(self):
campos = ['titulo', 'contenido']
nota = {c: '' for c in campos}
for campo in campos:
self.assertIn(campo, nota)
class TestStorage(unittest.TestCase):
def setUp(self):
self.temp = tempfile.mktemp(suffix='.json')
def tearDown(self):
if os.path.exists(self.temp):
os.remove(self.temp)
def test_guardar_nota(self):
from kivy.storage.jsonstore import JsonStore
store = JsonStore(self.temp)
store.put('nota1', titulo='Test', contenido='Hola')
self.assertTrue(store.exists('nota1'))
def test_listar_notas(self):
from kivy.storage.jsonstore import JsonStore
store = JsonStore(self.temp)
store.put('n1', titulo='Nota 1')
store.put('n2', titulo='Nota 2')
self.assertEqual(len(store.keys()), 2)
if __name__ == '__main__':
unittest.main()
Probar en Dispositivo con ADB
# Flujo completo de testing
adb install -r bin/notas-0.1-debug.apk
adb shell am start -n com.ejemplo.notas/org.kivy.android.PythonActivity
# Crear nota via comandos
adb shell input tap 500 900 # FAB para nueva nota
adb shell input text "Nota%sde%sprueba" # Escribir texto
adb shell input tap 500 100 # Guardar
# Verificar persistencia
adb shell am force-stop com.ejemplo.notas
adb shell am start -n com.ejemplo.notas/org.kivy.android.PythonActivity
# La nota debe seguir ahí
# Extraer base de datos para verificar
adb pull /data/data/com.ejemplo.notas/files/notas.json
# Test de estrés - crear muchas notas
for i in {1..10}; do
adb shell input tap 500 900
adb shell input text "Nota$i"
adb shell input tap 500 100
done
# Verificar que no hay memory leaks
adb shell dumpsys meminfo com.ejemplo.notas
Recursos Finales
| Recurso | URL |
|---|---|
| Documentación Kivy | https://kivy.org/doc/stable/ |
| KivyMD | https://kivymd.readthedocs.io/ |
| Discord Kivy | https://discord.gg/kivy |
| Stack Overflow | https://stackoverflow.com/questions/tagged/kivy |
| GitHub Kivy | https://github.com/kivy/kivy |