تطبيقات الويب التقدمية

بناء مشروع PWA (الجزء الثاني)

20 دقيقة الدرس 27 من 30

بناء مشروع PWA (الجزء الثاني)

في هذا الدرس، سنعزز تطبيق TaskMaster PWA الخاص بنا بميزات متقدمة بما في ذلك IndexedDB لاستمرارية البيانات، والإشعارات الفورية، والمزامنة في الخلفية، ومطالبة التثبيت.

دمج IndexedDB

لننشئ طبقة تجريد لقاعدة البيانات في js/db.js:

// db.js - IndexedDB wrapper const DB_NAME = 'TaskMasterDB'; const DB_VERSION = 1; const STORE_NAME = 'tasks'; class TaskDB { constructor() { this.db = null; } // Initialize database async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => { this.db = request.result; resolve(this.db); }; request.onupgradeneeded = (event) => { const db = event.target.result; // Create object store if (!db.objectStoreNames.contains(STORE_NAME)) { const objectStore = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); // Create indexes objectStore.createIndex('priority', 'priority', { unique: false }); objectStore.createIndex('completed', 'completed', { unique: false }); objectStore.createIndex('dueDate', 'dueDate', { unique: false }); objectStore.createIndex('createdAt', 'createdAt', { unique: false }); } }; }); } // Add task async addTask(task) { return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], 'readwrite'); const store = transaction.objectStore(STORE_NAME); const taskData = { title: task.title, description: task.description || '', priority: task.priority || 'medium', dueDate: task.dueDate || null, completed: false, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; const request = store.add(taskData); request.onsuccess = () => { taskData.id = request.result; resolve(taskData); }; request.onerror = () => reject(request.error); }); } // Get all tasks async getAllTasks() { return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], 'readonly'); const store = transaction.objectStore(STORE_NAME); const request = store.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } // Get task by ID async getTask(id) { return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], 'readonly'); const store = transaction.objectStore(STORE_NAME); const request = store.get(id); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } // Update task async updateTask(id, updates) { return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], 'readwrite'); const store = transaction.objectStore(STORE_NAME); const getRequest = store.get(id); getRequest.onsuccess = () => { const task = getRequest.result; if (!task) { reject(new Error('Task not found')); return; } Object.assign(task, updates, { updatedAt: new Date().toISOString() }); const updateRequest = store.put(task); updateRequest.onsuccess = () => resolve(task); updateRequest.onerror = () => reject(updateRequest.error); }; getRequest.onerror = () => reject(getRequest.error); }); } // Delete task async deleteTask(id) { return new Promise((resolve, reject) => { const transaction = this.db.transaction([STORE_NAME], 'readwrite'); const store = transaction.objectStore(STORE_NAME); const request = store.delete(id); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } // Get tasks by filter async getTasksByFilter(filter) { const allTasks = await this.getAllTasks(); switch (filter) { case 'active': return allTasks.filter(task => !task.completed); case 'completed': return allTasks.filter(task => task.completed); default: return allTasks; } } } // Export singleton instance const taskDB = new TaskDB();

تنفيذ منطق التطبيق

الآن لننفذ منطق التطبيق الرئيسي في js/app.js:

// app.js - Main application logic let currentFilter = 'all'; let deferredPrompt = null; // Initialize app async function initApp() { try { await taskDB.init(); await loadTasks(); setupEventListeners(); setupOnlineOfflineHandlers(); console.log('App initialized successfully'); } catch (error) { console.error('Failed to initialize app:', error); showError('فشل تهيئة التطبيق. يرجى تحديث الصفحة.'); } } // Setup event listeners function setupEventListeners() { // Task form submission document.getElementById('taskForm').addEventListener('submit', handleTaskSubmit); // Filter buttons document.querySelectorAll('.filter-btn').forEach(btn => { btn.addEventListener('click', handleFilterChange); }); // Install button window.addEventListener('beforeinstallprompt', handleInstallPrompt); document.getElementById('installBtn').addEventListener('click', handleInstallClick); } // Handle task form submission async function handleTaskSubmit(e) { e.preventDefault(); const title = document.getElementById('taskTitle').value.trim(); const description = document.getElementById('taskDescription').value.trim(); const priority = document.getElementById('taskPriority').value; const dueDate = document.getElementById('taskDueDate').value; if (!title) { showError('يرجى إدخال عنوان المهمة'); return; } try { const task = await taskDB.addTask({ title, description, priority, dueDate }); // Reset form e.target.reset(); // Reload tasks await loadTasks(); // Show success message showSuccess('تمت إضافة المهمة بنجاح!'); // Send push notification if supported if (Notification.permission === 'granted') { new Notification('تمت إضافة المهمة', { body: title, icon: '/images/icons/icon-192x192.png', badge: '/images/icons/icon-96x96.png', tag: 'task-added' }); } } catch (error) { console.error('Failed to add task:', error); showError('فشل إضافة المهمة. يرجى المحاولة مرة أخرى.'); } } // Load and display tasks async function loadTasks() { try { const tasks = await taskDB.getTasksByFilter(currentFilter); displayTasks(tasks); } catch (error) { console.error('Failed to load tasks:', error); showError('فشل تحميل المهام'); } } // Display tasks in UI function displayTasks(tasks) { const taskList = document.getElementById('taskList'); const emptyState = document.getElementById('emptyState'); if (tasks.length === 0) { taskList.innerHTML = ''; emptyState.style.display = 'block'; return; } emptyState.style.display = 'none'; taskList.innerHTML = tasks.map(task => ` <div class="task-item ${task.completed ? 'completed' : ''} priority-${task.priority}" data-id="${task.id}"> <div class="task-checkbox"> <input type="checkbox" ${task.completed ? 'checked' : ''} onchange="toggleTaskComplete(${task.id})" > </div> <div class="task-content"> <h3 class="task-title">${escapeHtml(task.title)}</h3> ${task.description ? `<p class="task-description">${escapeHtml(task.description)}</p>` : ''} <div class="task-meta"> <span class="priority-badge priority-${task.priority}"> ${task.priority} </span> ${task.dueDate ? `<span class="due-date">تاريخ الاستحقاق: ${formatDate(task.dueDate)}</span>` : ''} </div> </div> <div class="task-actions"> <button class="btn-icon" onclick="deleteTask(${task.id})" title="حذف"> <svg width="20" height="20" viewBox="0 0 24 24"> <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/> </svg> </button> </div> </div> `).join(''); } // Toggle task completion async function toggleTaskComplete(id) { try { const task = await taskDB.getTask(id); await taskDB.updateTask(id, { completed: !task.completed }); await loadTasks(); } catch (error) { console.error('Failed to toggle task:', error); } } // Delete task async function deleteTask(id) { if (!confirm('هل أنت متأكد من حذف هذه المهمة؟')) { return; } try { await taskDB.deleteTask(id); await loadTasks(); showSuccess('تم حذف المهمة بنجاح'); } catch (error) { console.error('Failed to delete task:', error); showError('فشل حذف المهمة'); } } // Handle filter change function handleFilterChange(e) { document.querySelectorAll('.filter-btn').forEach(btn => { btn.classList.remove('active'); }); e.target.classList.add('active'); currentFilter = e.target.dataset.filter; loadTasks(); } // Utility functions function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function formatDate(dateString) { const date = new Date(dateString); return date.toLocaleDateString('ar-SA', { month: 'short', day: 'numeric', year: 'numeric' }); } function showSuccess(message) { // Implement toast notification console.log('نجاح:', message); } function showError(message) { // Implement toast notification console.error('خطأ:', message); } // Initialize app when DOM is ready document.addEventListener('DOMContentLoaded', initApp);

تنفيذ الإشعارات الفورية

// Request notification permission async function requestNotificationPermission() { if (!('Notification' in window)) { console.log('هذا المتصفح لا يدعم الإشعارات'); return false; } if (Notification.permission === 'granted') { return true; } if (Notification.permission !== 'denied') { const permission = await Notification.requestPermission(); return permission === 'granted'; } return false; } // Setup push notifications async function setupPushNotifications() { const hasPermission = await requestNotificationPermission(); if (!hasPermission) { console.log('تم رفض إذن الإشعارات'); return; } // Register push subscription const registration = await navigator.serviceWorker.ready; try { const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array('YOUR_VAPID_PUBLIC_KEY') }); // Send subscription to server await fetch('/api/push-subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription) }); console.log('تم الاشتراك في الإشعارات الفورية بنجاح'); } catch (error) { console.error('فشل الاشتراك في الإشعارات الفورية:', error); } } function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/\-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; }

المزامنة في الخلفية للإجراءات دون اتصال

أضف إلى service worker (sw.js):

// Background sync event self.addEventListener('sync', event => { if (event.tag === 'sync-tasks') { event.waitUntil(syncTasks()); } }); async function syncTasks() { try { // Get pending sync data from IndexedDB const db = await openDatabase(); const pendingTasks = await getPendingTasks(db); // Sync with server for (const task of pendingTasks) { try { const response = await fetch('/api/tasks/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(task) }); if (response.ok) { await markTaskSynced(db, task.id); } } catch (error) { console.error('فشل مزامنة المهمة:', task.id, error); } } // Notify user self.registration.showNotification('تمت المزامنة', { body: `تمت مزامنة ${pendingTasks.length} مهام بنجاح`, icon: '/images/icons/icon-192x192.png', badge: '/images/icons/icon-96x96.png' }); } catch (error) { console.error('فشلت المزامنة في الخلفية:', error); throw error; } }

تنفيذ مطالبة التثبيت

// Handle install prompt function handleInstallPrompt(e) { // Prevent the mini-infobar from appearing e.preventDefault(); // Store the event for later use deferredPrompt = e; // Show install button const installBtn = document.getElementById('installBtn'); installBtn.style.display = 'block'; console.log('مطالبة التثبيت جاهزة'); } // Handle install button click async function handleInstallClick() { if (!deferredPrompt) { return; } // Show the install prompt deferredPrompt.prompt(); // Wait for the user's response const { outcome } = await deferredPrompt.userChoice; console.log(`استجابة المستخدم: ${outcome}`); // Clear the deferred prompt deferredPrompt = null; // Hide install button document.getElementById('installBtn').style.display = 'none'; } // Listen for app installed event window.addEventListener('appinstalled', () => { console.log('تم تثبيت PWA بنجاح'); // Hide install button document.getElementById('installBtn').style.display = 'none'; // Track installation if ('ga' in window) { ga('send', 'event', 'PWA', 'installed'); } });

معالجة حالة الاتصال/عدم الاتصال

// Setup online/offline handlers function setupOnlineOfflineHandlers() { updateOnlineStatus(); window.addEventListener('online', () => { updateOnlineStatus(); showSuccess('عدت متصلاً!'); // Trigger background sync if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) { navigator.serviceWorker.ready.then(registration => { return registration.sync.register('sync-tasks'); }); } }); window.addEventListener('offline', () => { updateOnlineStatus(); showError('أنت غير متصل. ستتم المزامنة عندما تعود للاتصال.'); }); } function updateOnlineStatus() { const statusIndicator = document.getElementById('onlineStatus'); const statusText = document.getElementById('statusText'); if (navigator.onLine) { statusIndicator.classList.remove('offline'); statusIndicator.classList.add('online'); statusText.textContent = 'متصل'; } else { statusIndicator.classList.remove('online'); statusIndicator.classList.add('offline'); statusText.textContent = 'غير متصل'; } }
أفضل ممارسة: قدم دائماً ملاحظات مرئية عندما ينقطع الاتصال بالتطبيق أو يعود. يجب أن يفهم المستخدمون حالة الاتصال الحالية والميزات المتاحة.
تمرين: نفذ دمج IndexedDB، أضف دعم الإشعارات الفورية، واختبر التطبيق في وضع عدم الاتصال. تحقق من حفظ المهام محلياً وأن مؤشر عدم الاتصال يعمل بشكل صحيح.
الخطوات التالية: في الجزء الثالث، سنضيف ميزات متقدمة، ونحسّن الأداء، وننشر التطبيق، ونجري تدقيق PWA كامل.