This commit is contained in:
36
.gitea/workflows/exec.yaml
Normal file
36
.gitea/workflows/exec.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Build Executable
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build-and-package:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Install PyInstaller
|
||||
run: pip install pyinstaller
|
||||
|
||||
- name: Build executable with PyInstaller
|
||||
run: pyinstaller --onefile --windowed --add-data "weather_icon.svg:." main.py
|
||||
|
||||
- name: Upload executable artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: executable
|
||||
path: dist/
|
||||
538
main.py
Normal file
538
main.py
Normal file
@@ -0,0 +1,538 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Advanced Weather App using Python and PyQt5
|
||||
Production-ready weather application with caching, error handling, and beautiful UI
|
||||
Uses Open-Meteo API (completely free, no API key required)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import pickle
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
|
||||
import requests
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QLineEdit, QPushButton, QMessageBox, QFrame,
|
||||
QScrollArea, QGridLayout, QProgressBar, QSystemTrayIcon, QMenu
|
||||
)
|
||||
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
|
||||
from PyQt5.QtGui import QFont, QPixmap, QPalette, QColor, QIcon
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
filename='weather_app.log',
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
class WeatherAPI:
|
||||
"""Handle all weather API interactions with Open-Meteo"""
|
||||
|
||||
BASE_URL = "https://api.open-meteo.com/v1/forecast"
|
||||
GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"
|
||||
|
||||
@staticmethod
|
||||
def get_coordinates(city_name: str) -> Optional[Tuple[float, float, str, str]]:
|
||||
"""Get coordinates for a city name using Open-Meteo geocoding"""
|
||||
try:
|
||||
params = {
|
||||
"name": city_name,
|
||||
"count": 1,
|
||||
"language": "en",
|
||||
"format": "json"
|
||||
}
|
||||
response = requests.get(WeatherAPI.GEOCODING_URL, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
results = data.get("results", [])
|
||||
|
||||
if results:
|
||||
location = results[0]
|
||||
return (
|
||||
location['latitude'],
|
||||
location['longitude'],
|
||||
location['name'],
|
||||
location.get('country', 'Unknown')
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Geocoding error for {city_name}: {e}")
|
||||
raise Exception(f"Could not find location: {city_name}")
|
||||
|
||||
@staticmethod
|
||||
def get_weather_data(lat: float, lon: float) -> Dict[str, Any]:
|
||||
"""Get current weather and 7-day forecast"""
|
||||
try:
|
||||
params = {
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"current_weather": "true",
|
||||
"daily": "temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum,windspeed_10m_max",
|
||||
"timezone": "auto",
|
||||
"forecast_days": 7
|
||||
}
|
||||
|
||||
response = requests.get(WeatherAPI.BASE_URL, params=params, timeout=15)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Weather API error for {lat}, {lon}: {e}")
|
||||
raise Exception("Failed to fetch weather data")
|
||||
|
||||
class WeatherWorker(QThread):
|
||||
"""Background thread for API calls"""
|
||||
data_ready = pyqtSignal(dict, str, str)
|
||||
error_occurred = pyqtSignal(str)
|
||||
|
||||
def __init__(self, city_name: str):
|
||||
super().__init__()
|
||||
self.city_name = city_name
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
# Get coordinates
|
||||
coords = WeatherAPI.get_coordinates(self.city_name)
|
||||
if not coords:
|
||||
self.error_occurred.emit(f"Location '{self.city_name}' not found")
|
||||
return
|
||||
|
||||
lat, lon, city, country = coords
|
||||
|
||||
# Get weather data
|
||||
weather_data = WeatherAPI.get_weather_data(lat, lon)
|
||||
self.data_ready.emit(weather_data, city, country)
|
||||
|
||||
except Exception as e:
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
class WeatherCache:
|
||||
"""Handle caching of weather data"""
|
||||
|
||||
def __init__(self, cache_dir: str = "weather_cache"):
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(exist_ok=True)
|
||||
self.cache_duration = timedelta(minutes=30) # Cache for 30 minutes
|
||||
|
||||
def get_cache_key(self, city: str) -> str:
|
||||
return f"{city.lower().replace(' ', '_')}.pkl"
|
||||
|
||||
def save_data(self, city: str, data: Dict[str, Any]):
|
||||
"""Save weather data to cache"""
|
||||
try:
|
||||
cache_data = {
|
||||
'timestamp': datetime.now(),
|
||||
'data': data
|
||||
}
|
||||
cache_file = self.cache_dir / self.get_cache_key(city)
|
||||
with open(cache_file, 'wb') as f:
|
||||
pickle.dump(cache_data, f)
|
||||
except Exception as e:
|
||||
logging.error(f"Cache save error: {e}")
|
||||
|
||||
def load_data(self, city: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load weather data from cache if recent enough"""
|
||||
try:
|
||||
cache_file = self.cache_dir / self.get_cache_key(city)
|
||||
if not cache_file.exists():
|
||||
return None
|
||||
|
||||
with open(cache_file, 'rb') as f:
|
||||
cache_data = pickle.load(f)
|
||||
|
||||
# Check if cache is still valid
|
||||
if datetime.now() - cache_data['timestamp'] < self.cache_duration:
|
||||
return cache_data['data']
|
||||
else:
|
||||
cache_file.unlink() # Remove expired cache
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Cache load error: {e}")
|
||||
return None
|
||||
|
||||
class WeatherCard(QFrame):
|
||||
"""Widget for displaying daily weather forecast"""
|
||||
|
||||
def __init__(self, date: str, temp_max: float, temp_min: float, weather_code: int):
|
||||
super().__init__()
|
||||
self.setFrameStyle(QFrame.StyledPanel)
|
||||
self.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin: 5px;
|
||||
}
|
||||
""")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Date
|
||||
date_label = QLabel(date)
|
||||
date_label.setAlignment(Qt.AlignCenter)
|
||||
date_label.setFont(QFont("Segoe UI", 10, QFont.Bold))
|
||||
|
||||
# Weather icon (text representation)
|
||||
icon_label = QLabel(self.get_weather_icon(weather_code))
|
||||
icon_label.setAlignment(Qt.AlignCenter)
|
||||
icon_label.setFont(QFont("Segoe UI Emoji", 24))
|
||||
|
||||
# Temperature
|
||||
temp_label = QLabel(f"{int(temp_max)}° / {int(temp_min)}°")
|
||||
temp_label.setAlignment(Qt.AlignCenter)
|
||||
temp_label.setFont(QFont("Segoe UI", 11))
|
||||
|
||||
layout.addWidget(date_label)
|
||||
layout.addWidget(icon_label)
|
||||
layout.addWidget(temp_label)
|
||||
|
||||
self.setLayout(layout)
|
||||
self.setFixedSize(120, 150)
|
||||
|
||||
@staticmethod
|
||||
def get_weather_icon(weather_code: int) -> str:
|
||||
"""Map weather codes to emoji icons"""
|
||||
icons = {
|
||||
0: "☀️", # Clear sky
|
||||
1: "🌤️", # Mainly clear
|
||||
2: "⛅", # Partly cloudy
|
||||
3: "☁️", # Overcast
|
||||
45: "🌫️", # Fog
|
||||
48: "🌫️", # Depositing rime fog
|
||||
51: "🌦️", # Light drizzle
|
||||
53: "🌦️", # Moderate drizzle
|
||||
55: "🌧️", # Dense drizzle
|
||||
61: "🌧️", # Slight rain
|
||||
63: "🌧️", # Moderate rain
|
||||
65: "⛈️", # Heavy rain
|
||||
71: "🌨️", # Slight snow
|
||||
73: "❄️", # Moderate snow
|
||||
75: "❄️", # Heavy snow
|
||||
95: "⛈️", # Thunderstorm
|
||||
96: "⛈️", # Thunderstorm with hail
|
||||
99: "⛈️" # Heavy thunderstorm with hail
|
||||
}
|
||||
return icons.get(weather_code, "🌡️")
|
||||
|
||||
class WeatherApp(QMainWindow):
|
||||
"""Main weather application window"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.cache = WeatherCache()
|
||||
self.current_city = None
|
||||
self.setup_ui()
|
||||
self.setup_tray()
|
||||
self.setup_auto_refresh()
|
||||
|
||||
def setup_ui(self):
|
||||
"""Setup the main user interface"""
|
||||
self.setWindowTitle("Advanced Weather App")
|
||||
self.setGeometry(100, 100, 800, 600)
|
||||
self.setStyleSheet("""
|
||||
QMainWindow {
|
||||
background-color: #1e1e2f;
|
||||
}
|
||||
QLabel {
|
||||
color: #f0f0f0;
|
||||
font-family: 'Segoe UI', 'Roboto', 'Helvetica Neue', sans-serif;
|
||||
}
|
||||
QLineEdit {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #555;
|
||||
background-color: #2c2c3c;
|
||||
color: #f0f0f0;
|
||||
font-size: 14px;
|
||||
}
|
||||
QPushButton {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background-color: #5e60ce;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #4ea8de;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #3a86ff;
|
||||
}
|
||||
QProgressBar {
|
||||
border-radius: 5px;
|
||||
height: 12px;
|
||||
background-color: #2e2e38;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background-color: #5e60ce;
|
||||
border-radius: 5px;
|
||||
}
|
||||
QScrollArea {
|
||||
border: none;
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
central_widget = QWidget()
|
||||
self.setCentralWidget(central_widget)
|
||||
|
||||
main_layout = QVBoxLayout()
|
||||
|
||||
# Search section
|
||||
search_layout = QHBoxLayout()
|
||||
self.city_input = QLineEdit()
|
||||
self.city_input.setPlaceholderText("Enter city name...")
|
||||
self.city_input.returnPressed.connect(self.search_weather)
|
||||
|
||||
self.search_button = QPushButton("Search")
|
||||
self.search_button.clicked.connect(self.search_weather)
|
||||
|
||||
search_layout.addWidget(self.city_input)
|
||||
search_layout.addWidget(self.search_button)
|
||||
|
||||
# Progress bar
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setVisible(False)
|
||||
|
||||
# Current weather section
|
||||
self.current_weather_frame = QFrame()
|
||||
self.current_weather_frame.setStyleSheet("""
|
||||
QFrame {
|
||||
background-color: #2e2e3e;
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
""")
|
||||
|
||||
current_layout = QVBoxLayout()
|
||||
|
||||
self.location_label = QLabel("Search for a city to see weather")
|
||||
self.location_label.setAlignment(Qt.AlignCenter)
|
||||
self.location_label.setFont(QFont("Arial", 18, QFont.Bold))
|
||||
|
||||
self.temperature_label = QLabel("")
|
||||
self.temperature_label.setAlignment(Qt.AlignCenter)
|
||||
self.temperature_label.setFont(QFont("Arial", 48, QFont.Bold))
|
||||
|
||||
self.weather_icon_label = QLabel("")
|
||||
self.weather_icon_label.setAlignment(Qt.AlignCenter)
|
||||
self.weather_icon_label.setFont(QFont("Arial", 60))
|
||||
|
||||
self.description_label = QLabel("")
|
||||
self.description_label.setAlignment(Qt.AlignCenter)
|
||||
self.description_label.setFont(QFont("Arial", 16))
|
||||
|
||||
current_layout.addWidget(self.location_label)
|
||||
current_layout.addWidget(self.weather_icon_label)
|
||||
current_layout.addWidget(self.temperature_label)
|
||||
current_layout.addWidget(self.description_label)
|
||||
|
||||
self.current_weather_frame.setLayout(current_layout)
|
||||
|
||||
# Forecast section
|
||||
forecast_label = QLabel("7-Day Forecast")
|
||||
forecast_label.setAlignment(Qt.AlignCenter)
|
||||
forecast_label.setFont(QFont("Arial", 16, QFont.Bold))
|
||||
|
||||
# Scrollable forecast area
|
||||
scroll_area = QScrollArea()
|
||||
scroll_widget = QWidget()
|
||||
self.forecast_layout = QHBoxLayout()
|
||||
scroll_widget.setLayout(self.forecast_layout)
|
||||
scroll_area.setWidget(scroll_widget)
|
||||
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll_area.setFixedHeight(200)
|
||||
|
||||
# Add all widgets to main layout
|
||||
main_layout.addLayout(search_layout)
|
||||
main_layout.addWidget(self.progress_bar)
|
||||
main_layout.addWidget(self.current_weather_frame)
|
||||
main_layout.addWidget(forecast_label)
|
||||
main_layout.addWidget(scroll_area)
|
||||
|
||||
central_widget.setLayout(main_layout)
|
||||
|
||||
# Status bar
|
||||
self.statusBar().showMessage("Ready")
|
||||
|
||||
def setup_tray(self):
|
||||
"""Setup system tray icon"""
|
||||
if QSystemTrayIcon.isSystemTrayAvailable():
|
||||
self.tray_icon = QSystemTrayIcon(self)
|
||||
self.tray_icon.setIcon(self.style().standardIcon(self.style().SP_ComputerIcon))
|
||||
|
||||
tray_menu = QMenu(self)
|
||||
show_action = tray_menu.addAction("Show")
|
||||
show_action.triggered.connect(self.show)
|
||||
quit_action = tray_menu.addAction("Quit")
|
||||
quit_action.triggered.connect(self.close)
|
||||
|
||||
self.tray_icon.setContextMenu(tray_menu)
|
||||
self.tray_icon.show()
|
||||
|
||||
def setup_auto_refresh(self):
|
||||
"""Setup automatic weather refresh every 30 minutes"""
|
||||
self.refresh_timer = QTimer()
|
||||
self.refresh_timer.timeout.connect(self.auto_refresh_weather)
|
||||
self.refresh_timer.start(30 * 60 * 1000) # 30 minutes
|
||||
|
||||
def auto_refresh_weather(self):
|
||||
"""Automatically refresh weather for current city"""
|
||||
if self.current_city:
|
||||
self.search_weather()
|
||||
|
||||
def search_weather(self):
|
||||
"""Search for weather data"""
|
||||
city = self.city_input.text().strip()
|
||||
if not city:
|
||||
QMessageBox.warning(self, "Warning", "Please enter a city name")
|
||||
return
|
||||
|
||||
# Check cache first
|
||||
cached_data = self.cache.load_data(city)
|
||||
if cached_data:
|
||||
self.display_weather(cached_data, city, "")
|
||||
self.statusBar().showMessage(f"Showing cached data for {city}")
|
||||
return
|
||||
|
||||
# Show loading state
|
||||
self.progress_bar.setVisible(True)
|
||||
self.search_button.setEnabled(False)
|
||||
self.statusBar().showMessage(f"Searching weather for {city}...")
|
||||
|
||||
# Start background API call
|
||||
self.worker = WeatherWorker(city)
|
||||
self.worker.data_ready.connect(self.on_weather_data_ready)
|
||||
self.worker.error_occurred.connect(self.on_weather_error)
|
||||
self.worker.start()
|
||||
|
||||
def on_weather_data_ready(self, data: Dict[str, Any], city: str, country: str):
|
||||
"""Handle successful weather data retrieval"""
|
||||
self.progress_bar.setVisible(False)
|
||||
self.search_button.setEnabled(True)
|
||||
|
||||
# Cache the data
|
||||
self.cache.save_data(city, data)
|
||||
|
||||
# Display the weather
|
||||
self.display_weather(data, city, country)
|
||||
self.current_city = city
|
||||
|
||||
self.statusBar().showMessage(f"Weather updated for {city}")
|
||||
logging.info(f"Weather data retrieved for {city}")
|
||||
|
||||
def on_weather_error(self, error_message: str):
|
||||
"""Handle weather data retrieval errors"""
|
||||
self.progress_bar.setVisible(False)
|
||||
self.search_button.setEnabled(True)
|
||||
|
||||
QMessageBox.critical(self, "Error", error_message)
|
||||
self.statusBar().showMessage("Error occurred")
|
||||
logging.error(f"Weather error: {error_message}")
|
||||
|
||||
def display_weather(self, data: Dict[str, Any], city: str, country: str):
|
||||
"""Display weather data in the UI"""
|
||||
try:
|
||||
current = data['current_weather']
|
||||
daily = data['daily']
|
||||
|
||||
# Update current weather
|
||||
location_text = f"{city}"
|
||||
if country:
|
||||
location_text += f", {country}"
|
||||
self.location_label.setText(location_text)
|
||||
|
||||
temp = int(current['temperature'])
|
||||
self.temperature_label.setText(f"{temp}°C")
|
||||
|
||||
weather_code = current['weathercode']
|
||||
icon = WeatherCard.get_weather_icon(weather_code)
|
||||
self.weather_icon_label.setText(icon)
|
||||
|
||||
# Weather description
|
||||
descriptions = {
|
||||
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
|
||||
45: "Foggy", 48: "Depositing rime fog", 51: "Light drizzle",
|
||||
53: "Moderate drizzle", 55: "Dense drizzle", 61: "Light rain",
|
||||
63: "Moderate rain", 65: "Heavy rain", 71: "Light snow",
|
||||
73: "Moderate snow", 75: "Heavy snow", 95: "Thunderstorm",
|
||||
96: "Thunderstorm with hail", 99: "Heavy thunderstorm"
|
||||
}
|
||||
description = descriptions.get(weather_code, "Unknown")
|
||||
wind_speed = current.get('windspeed', 0)
|
||||
self.description_label.setText(f"{description} • Wind: {int(wind_speed)} km/h")
|
||||
|
||||
# Clear existing forecast
|
||||
for i in reversed(range(self.forecast_layout.count())):
|
||||
child = self.forecast_layout.itemAt(i).widget()
|
||||
if child:
|
||||
child.setParent(None)
|
||||
|
||||
# Add forecast cards
|
||||
dates = daily['time']
|
||||
temp_max = daily['temperature_2m_max']
|
||||
temp_min = daily['temperature_2m_min']
|
||||
weather_codes = daily['weathercode']
|
||||
|
||||
for i in range(len(dates)):
|
||||
date_str = datetime.fromisoformat(dates[i]).strftime("%a\n%m/%d")
|
||||
card = WeatherCard(date_str, temp_max[i], temp_min[i], weather_codes[i])
|
||||
self.forecast_layout.addWidget(card)
|
||||
|
||||
# Update tray tooltip if available
|
||||
if hasattr(self, 'tray_icon'):
|
||||
self.tray_icon.setToolTip(f"{city}: {temp}°C - {description}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Display error: {e}")
|
||||
QMessageBox.critical(self, "Error", "Failed to display weather data")
|
||||
|
||||
def main():
|
||||
"""Main application entry point"""
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Advanced Weather App")
|
||||
app.setQuitOnLastWindowClosed(False) # Keep running in system tray
|
||||
|
||||
# Set application icon
|
||||
app.setWindowIcon(app.style().standardIcon(app.style().SP_ComputerIcon))
|
||||
|
||||
window = WeatherApp()
|
||||
window.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
app.setStyle("Fusion")
|
||||
|
||||
# Optional dark Fusion palette
|
||||
palette = QPalette()
|
||||
palette.setColor(QPalette.Window, QColor(44, 47, 59))
|
||||
palette.setColor(QPalette.WindowText, Qt.white)
|
||||
palette.setColor(QPalette.Base, QColor(25, 25, 25))
|
||||
palette.setColor(QPalette.AlternateBase, QColor(44, 47, 59))
|
||||
palette.setColor(QPalette.ToolTipBase, Qt.white)
|
||||
palette.setColor(QPalette.ToolTipText, Qt.white)
|
||||
palette.setColor(QPalette.Text, Qt.white)
|
||||
palette.setColor(QPalette.Button, QColor(70, 70, 90))
|
||||
palette.setColor(QPalette.ButtonText, Qt.white)
|
||||
palette.setColor(QPalette.BrightText, Qt.red)
|
||||
palette.setColor(QPalette.Link, QColor(42, 130, 218))
|
||||
palette.setColor(QPalette.Highlight, QColor(42, 130, 218))
|
||||
palette.setColor(QPalette.HighlightedText, Qt.black)
|
||||
app.setPalette(palette)
|
||||
|
||||
window = WeatherApp()
|
||||
window.show()
|
||||
sys.exit(app.exec_())
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
PyQt5>=5.15.0
|
||||
PyQt5-Qt5>=5.15.0
|
||||
PyQt5-sip>=12.8.0
|
||||
requests>=2.20.0
|
||||
5
weather_icon.svg
Normal file
5
weather_icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="32" cy="32" r="30" fill="#3A3F4B"/>
|
||||
<path d="M20 38c0-6.6 5.4-12 12-12 4.2 0 8 2.2 10.2 5.8C44.9 32 48 35.1 48 39.3 48 43.9 44.4 48 39.5 48H22c-4.4 0-8-3.6-8-8s3.6-8 8-8z" fill="#FFFFFF"/>
|
||||
<path d="M30 50l-4 8h8l-4 8" stroke="#FFCC00" stroke-width="3" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 375 B |
Reference in New Issue
Block a user