← Volver al listado de tecnologías

Proyecto Final - App de Notas

Por: SiempreListo
kivyproyectokivymdcrudnotas

Descripción del Proyecto

Construiremos una app de notas completa que integra los conceptos aprendidos:

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

Resumen del Curso

A lo largo de este tutorial aprendiste:

  1. Fundamentos: Instalación, estructura de apps, ciclo de vida
  2. UI: Widgets, layouts, lenguaje KV
  3. Interactividad: Propiedades, eventos, binding
  4. Gráficos: Canvas, animaciones, Clock
  5. Navegación: ScreenManager, transiciones
  6. Material Design: KivyMD para UI moderna
  7. Extensiones: Kivy Garden
  8. Producción: Buildozer, APIs nativas
  9. Juegos: Game loop, colisiones, sprites
  10. Arquitectura: Patrones, testing, optimización

Próximos Pasos

  1. Practica construyendo proyectos propios
  2. Contribuye a Kivy Garden
  3. Explora KivEnt para juegos complejos
  4. 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

RecursoURL
Documentación Kivyhttps://kivy.org/doc/stable/
KivyMDhttps://kivymd.readthedocs.io/
Discord Kivyhttps://discord.gg/kivy
Stack Overflowhttps://stackoverflow.com/questions/tagged/kivy
GitHub Kivyhttps://github.com/kivy/kivy