¿Qué vamos a construir?
Una app del clima que consulta la API de OpenWeatherMap en tiempo real. Desde la interfaz podés buscar cualquier ciudad y ver:
- Temperatura actual en grados Celsius
- Sensación térmica y descripción del estado del cielo
- Humedad relativa y velocidad del viento
- Ícono visual según el código meteorológico
La app arranca con Medellín por defecto y se puede usar en el navegador gracias al modo WEB_BROWSER de Flet.
El código está organizado en capas bien separadas para que cada parte sea fácil de mantener o reemplazar:
config/— ajustes globales y variables de entornoservices/— toda la lógica de llamadas a la APIcomponents/— piezas reutilizables de la interfazutils/— funciones auxiliares pequeñas
API key y variables de entorno
Antes de escribir una línea de código necesitás una API key gratuita de OpenWeatherMap. Entrá a openweathermap.org, creá una cuenta y generá una clave desde la sección API keys de tu perfil.
Con la clave en mano, creá un archivo .env en la raíz del proyecto:
WEATHER_API_KEY=tu_clave_aqui
Ese archivo lo lee config/settings.py con python-dotenv:
import os
from dotenv import load_dotenv
load_dotenv()
WEATHER_API_KEY = os.getenv("WEATHER_API_KEY", "")
BASE_URL = "https://api.openweathermap.org/data/2.5"
GEOCODING_URL = "https://api.openweathermap.org/geo/1.0"
APP_TITLE = "Clima App"
DEFAULT_CITY = "Medellín"
.env al repositorio. Agregalo a .gitignore antes de hacer el primer commit: echo ".env" >> .gitignore. La API key es tuya y tiene límites de uso.
Instalar dependencias
El proyecto usa solo tres paquetes. El archivo requirements.txt en la raíz los declara:
flet>=0.21.0
requests>=2.31.0
python-dotenv>=1.0.0
Para qué sirve cada uno:
- flet — framework de UI en Python basado en Flutter. Permite construir apps de escritorio, móvil y web desde un mismo código.
- requests — librería estándar para hacer peticiones HTTP. La usamos para consultar los endpoints de OpenWeatherMap.
- python-dotenv — carga las variables del archivo
.enval entorno del proceso sin tener que exportarlas manualmente.
Instalá todo con un solo comando:
pip install -r requirements.txt
Estructura del proyecto
Así queda el árbol de archivos completo:
weather_app/
├── main.py
├── requirements.txt
├── .env ← no commitear
├── config/
│ └── settings.py
├── services/
│ └── weather_service.py
├── components/
│ ├── weather_card.py
│ └── search_bar.py
└── utils/
└── helpers.py
Cada capa tiene una responsabilidad clara: services/ habla con la API, components/ construye la UI, utils/ agrupa funciones pequeñas sin estado, y config/ centraliza todas las constantes. El archivo main.py solo ensambla las piezas.
El servicio de clima: weather_service.py
La clase WeatherService encapsula todas las llamadas a la API. Tiene tres métodos:
import requests
from config.settings import WEATHER_API_KEY, BASE_URL, GEOCODING_URL
class WeatherService:
def get_coordinates(self, city_name):
url = f"{GEOCODING_URL}/direct"
params = {"q": city_name, "limit": 1, "appid": WEATHER_API_KEY}
try:
response = requests.get(url, params=params)
data = response.json()
if data:
return data[0]["lat"], data[0]["lon"]
return None, None
except Exception as e:
print(f"Error obteniendo coordenadas: {e}")
return None, None
def get_weather(self, city_name):
lat, lon = self.get_coordinates(city_name)
if lat is None:
return None
url = f"{BASE_URL}/weather"
params = {
"lat": lat, "lon": lon,
"units": "metric", "lang": "es",
"appid": WEATHER_API_KEY
}
try:
response = requests.get(url, params=params)
return response.json()
except Exception as e:
print(f"Error obteniendo clima: {e}")
return None
def get_forecast(self, city_name, days=5):
lat, lon = self.get_coordinates(city_name)
if lat is None:
return None
url = f"{BASE_URL}/forecast"
params = {
"lat": lat, "lon": lon,
"cnt": days * 8,
"units": "metric", "lang": "es",
"appid": WEATHER_API_KEY
}
try:
response = requests.get(url, params=params)
return response.json()
except Exception as e:
print(f"Error obteniendo pronóstico: {e}")
return None
El flujo de get_weather es intencional: primero llama a la Geocoding API para convertir el nombre de la ciudad en coordenadas lat/lon, y luego usa esas coordenadas para pedir el clima. Esto es más preciso que pasar el nombre directamente, especialmente para ciudades colombianas o municipios pequeños donde la búsqueda por nombre puede devolver resultados de otro país.
Componentes reutilizables
Flet tiene el concepto de UserControl: una clase que empaqueta un fragmento de UI con su propio estado y lógica. Así se puede reutilizar en cualquier parte de la app sin duplicar código.
SearchBar — barra de búsqueda con campo de texto y botón:
import flet as ft
class SearchBar(ft.UserControl):
def __init__(self, on_search):
super().__init__()
self.on_search = on_search
def build(self):
self.text_field = ft.TextField(
hint_text="Buscar ciudad...",
prefix_icon=ft.icons.SEARCH,
on_submit=self.search_city,
expand=True,
)
return ft.Row([
self.text_field,
ft.IconButton(
icon=ft.icons.SEARCH,
on_click=self.search_city,
),
])
def search_city(self, e):
city = self.text_field.value.strip()
if city:
self.on_search(city)
El patrón on_search es un callback: el componente no sabe qué va a pasar cuando el usuario busca, solo lo notifica. Quien instancia SearchBar decide qué hacer con el nombre de la ciudad.
WeatherCard — tarjeta que muestra los datos del clima:
import flet as ft
from utils.helpers import get_weather_icon, format_temperature, capitalize_first
class WeatherCard(ft.UserControl):
def build(self):
self.city_title = ft.Text("", size=24, weight=ft.FontWeight.BOLD)
self.weather_icon = ft.Text("", size=64)
self.temperature = ft.Text("", size=48, weight=ft.FontWeight.BOLD)
self.description = ft.Text("", size=16)
self.feels_like = ft.Text("")
self.humidity = ft.Text("")
self.wind_speed = ft.Text("")
return ft.Card(
content=ft.Container(
padding=20,
content=ft.Column([
self.city_title,
self.weather_icon,
self.temperature,
self.description,
ft.Row([
self.feels_like,
self.humidity,
self.wind_speed,
], alignment=ft.MainAxisAlignment.SPACE_AROUND),
], horizontal_alignment=ft.CrossAxisAlignment.CENTER),
)
)
def update_weather(self, weather_data):
name = weather_data["name"]
country = weather_data["sys"]["country"]
temp = weather_data["main"]["temp"]
feels = weather_data["main"]["feels_like"]
hum = weather_data["main"]["humidity"]
wind_ms = weather_data["wind"]["speed"]
icon_code = weather_data["weather"][0]["icon"]
description = weather_data["weather"][0]["description"]
self.city_title.value = f"{name}, {country}"
self.weather_icon.value = get_weather_icon(icon_code)
self.temperature.value = format_temperature(temp)
self.description.value = capitalize_first(description)
self.feels_like.value = f"Sensación {format_temperature(feels)}"
self.humidity.value = f"Humedad {hum}%"
self.wind_speed.value = f"Viento {int(wind_ms * 3.6)} km/h"
self.update()
m/s × 3.6 para pasar a km/h. La API devuelve metros por segundo por defecto incluso con units=metric.
helpers.py — funciones auxiliares sin estado:
def get_weather_icon(code):
icons = {
"01d": "☀️", "01n": "🌙",
"02d": "⛅", "02n": "⛅",
"03d": "☁️", "03n": "☁️",
"04d": "☁️", "04n": "☁️",
"09d": "🌧️", "09n": "🌧️",
"10d": "🌦️", "10n": "🌦️",
"11d": "⛈️", "11n": "⛈️",
"13d": "❄️", "13n": "❄️",
"50d": "🌫️", "50n": "🌫️",
}
return icons.get(code, "🌡️")
def format_temperature(temp):
return f"{int(temp)}°C"
def capitalize_first(text):
return text[0].upper() + text[1:] if text else text
El punto de entrada: main.py
La clase WeatherApp ensambla todo y controla el flujo de la interfaz:
import flet as ft
from config.settings import APP_TITLE, DEFAULT_CITY
from services.weather_service import WeatherService
from components.search_bar import SearchBar
from components.weather_card import WeatherCard
class WeatherApp:
def __init__(self, page: ft.Page):
self.page = page
self.service = WeatherService()
self.setup_page()
self.build_ui()
self.search_weather(DEFAULT_CITY)
def setup_page(self):
self.page.title = APP_TITLE
self.page.theme_mode = ft.ThemeMode.DARK
self.page.padding = 20
self.page.window_width = 420
self.page.window_height = 680
def build_ui(self):
self.title = ft.Text(APP_TITLE, size=28, weight=ft.FontWeight.BOLD)
self.search_bar = SearchBar(on_search=self.search_weather)
self.progress = ft.ProgressRing(visible=False)
self.error_text = ft.Text("", color=ft.colors.RED_400, visible=False)
self.weather_card = WeatherCard()
self.page.add(
self.title,
self.search_bar,
self.progress,
self.error_text,
self.weather_card,
)
def search_weather(self, city):
self.show_loading()
self.hide_error()
weather_data = self.service.get_weather(city)
self.hide_loading()
if weather_data and weather_data.get("cod") == 200:
self.weather_card.update_weather(weather_data)
else:
self.show_error(f"No se encontró información para '{city}'.")
def show_loading(self):
self.progress.visible = True
self.progress.update()
def hide_loading(self):
self.progress.visible = False
self.progress.update()
def show_error(self, message):
self.error_text.value = message
self.error_text.visible = True
self.error_text.update()
def hide_error(self):
self.error_text.visible = False
self.error_text.update()
def main(page: ft.Page):
WeatherApp(page)
ft.app(target=main, view=ft.WEB_BROWSER, port=8550)
El flujo cuando el usuario busca una ciudad es lineal y fácil de seguir: search_weather llama a show_loading, luego pide los datos al servicio, luego llama a hide_loading, y según el resultado actualiza la tarjeta o muestra un mensaje de error.
port=8550 evita conflictos con otros servicios que corren en puertos comunes como 8000 o 8080. Podés cambiarlo si ese puerto está ocupado en tu máquina.
Correr la app
Con el entorno virtual activo y las dependencias instaladas, ejecutá:
python main.py
Flet abre automáticamente el navegador en http://localhost:8550. Si no se abre solo, entrá a esa URL manualmente.
El flujo completo desde el navegador:
- La app carga y busca Medellín por defecto — deberías ver temperatura y datos de la ciudad
- Escribí el nombre de otra ciudad en la barra de búsqueda
- Presioná Enter o hacé clic en el botón de lupa
- El indicador de carga aparece brevemente mientras se consulta la API
- La tarjeta se actualiza con los datos de la nueva ciudad
- Si escribís un nombre que no existe, aparece el mensaje de error en rojo