← الدرس السابق المستوى 4 - الدرس 11 من 12

🏗️ مشروع Angular عملي: بناء تطبيق قائمة مهام (Todo App) كامل

تقدمك في المستوى الرابع 91%

📋 لماذا نبني مشروع عملي؟

في الدرس السابق تعلمنا النماذج (Forms) في Angular. دلوقتي نجمع كل اللي تعلمناه في مشروع عملي كامل: تطبيق قائمة مهام (Todo App). المشروع ده هيجمع: Components, Services, Dependency Injection, Forms, و Directives - كلهم في تطبيق واحد مترابط.

المشروع هيكون بسيط لكن كامل: تقدر تضيف مهمة، تسميها منجزة، تحذفها، وتشوف إحصائيات. ده نفس المفهوم اللي بنيناه في React في الدرس 5، لكن دلوقتي بنستخدم Angular عشان تشوف الفرق بين الإطارين.

بنهاية هذا الدرس، هتكون قادر على:

🎯 هدف المشروع: بناء Todo App يحتوي على: إضافة مهمة، تحديد كمنجزة، حذف مهمة، فلترة (الكل/المنجزة/المتبقية)، وحفظ في localStorage.

⚡ إعداد المشروع

أول خطوة: ننشئ مشروع Angular جديد ونضيف المكونات اللي محتاجينها.

الخطوة 1: إنشاء المشروع

# إنشاء مشروع جديد ng new angular-todo-app --routing --style=css cd angular-todo-app # تشغيل السيرفر ng serve

الخطوة 2: إنشاء المكونات

# إنشاء المكونات ng g c components/todo-list ng g c components/todo-item ng g c components/todo-form ng g c components/todo-stats # إنشاء الـ Service ng g s services/todo # إنشاء الـ Interface ng g interface models/todo

الخطوة 3: هيكل المشروع النهائي

src/app/ ├── components/ │ ├── todo-list/ // قائمة المهام الرئيسية │ ├── todo-item/ // عنصر مهمة واحدة │ ├── todo-form/ // نموذج إضافة مهمة │ └── todo-stats/ // إحصائيات المهام ├── services/ │ └── todo.service.ts // إدارة البيانات ├── models/ │ └── todo.ts // تعريف نوع البيانات ├── app.component.html ├── app.component.ts └── app.module.ts

📦 الخطوة 4: تعريف نوع البيانات (Interface)

أول حاجة: نعرف شكل بيانات المهمة. ده بيساعد TypeScript يكتشف الأخطاء قبل ما التطبيق يشتغل.

// models/todo.ts - تعريف شكل بيانات المهمة export interface Todo { id: number; // معرف فريد title: string; // عنوان المهمة completed: boolean; // هل تمت؟ createdAt: Date; // تاريخ الإنشاء priority: 'low' | 'medium' | 'high'; // الأولوية }

⚙️ الخطوة 5: إنشاء TodoService - إدارة البيانات

الـ Service هو "عقل" التطبيق. هنا بنخزن المهام، بنضيف، بنحذف، ونحفظ في localStorage.

// services/todo.service.ts import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { Todo } from '../models/todo'; @Injectable({ providedIn: 'root' }) export class TodoService { // ✅ BehaviorSubject: بيحفظ آخر قيمة ويبعتها لأي مشترك جديد private todosSubject = new BehaviorSubject<Todo[]>([]); todos$: Observable<Todo[]> = this.todosSubject.asObservable(); private filterSubject = new BehaviorSubject<string>('all'); filter$: Observable<string> = this.filterSubject.asObservable(); private readonly STORAGE_KEY = 'angular_todo_app'; constructor() { // تحميل المهام من localStorage لما الـ Service يتنشئ this.loadFromStorage(); } // ➕ إضافة مهمة جديدة addTodo(title: string, priority: 'low' | 'medium' | 'high' = 'medium') { const newTodo: Todo = { id: Date.now(), title: title.trim(), completed: false, createdAt: new Date(), priority: priority }; const currentTodos = this.todosSubject.getValue(); this.todosSubject.next([newTodo, ...currentTodos]); this.saveToStorage(); } // ✅ تبديل حالة المهمة (منجزة/غير منجزة) toggleTodo(id: number) { const todos = this.todosSubject.getValue().map(todo => todo.id === id ? { ...todo, completed: !todo.completed } : todo ); this.todosSubject.next(todos); this.saveToStorage(); } // 🗑️ حذف مهمة deleteTodo(id: number) { const todos = this.todosSubject.getValue().filter(todo => todo.id !== id); this.todosSubject.next(todos); this.saveToStorage(); } // 🧹 حذف كل المهام المنجزة clearCompleted() { const todos = this.todosSubject.getValue().filter(todo => !todo.completed); this.todosSubject.next(todos); this.saveToStorage(); } // 🔍 تغيير الفلتر setFilter(filter: 'all' | 'active' | 'completed') { this.filterSubject.next(filter); } // 💾 حفظ في localStorage private saveToStorage() { const todos = this.todosSubject.getValue(); localStorage.setItem(this.STORAGE_KEY, JSON.stringify(todos)); } // 📂 تحميل من localStorage private loadFromStorage() { const stored = localStorage.getItem(this.STORAGE_KEY); if (stored) { const todos = JSON.parse(stored).map((todo: any) => ({ ...todo, createdAt: new Date(todo.createdAt) })); this.todosSubject.next(todos); } } }
📌 ليه استخدمنا BehaviorSubject مش Array عادي؟

BehaviorSubject بيسمح لأي مكون يشترك في المهام ويتحدث تلقائياً لما المهام تتغير. ده نفس مفهوم Redux أو Context API في React. لو استخدمنا Array عادي، المكونات مش هتعرف إن البيانات اتغيرت.

🧩 الخطوة 6: بناء المكونات

6.1 TodoForm Component - نموذج إضافة مهمة

// components/todo-form/todo-form.component.ts import { Component } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { TodoService } from '../../services/todo.service'; @Component({ selector: 'app-todo-form', templateUrl: './todo-form.component.html' }) export class TodoFormComponent { todoForm: FormGroup; constructor( private fb: FormBuilder, private todoService: TodoService ) { this.todoForm = this.fb.group({ title: ['', [Validators.required, Validators.minLength(3)]], priority: ['medium'] }); } onSubmit() { if (this.todoForm.valid) { const { title, priority } = this.todoForm.value; this.todoService.addTodo(title, priority); this.todoForm.reset({ priority: 'medium' }); } } get title() { return this.todoForm.get('title'); } }
<!-- todo-form.component.html --> <form [formGroup]="todoForm" (ngSubmit)="onSubmit()" class="todo-form"> <div class="form-row"> <input type="text" formControlName="title" placeholder="أضف مهمة جديدة..." class="form-control" > <select formControlName="priority" class="form-select"> <option value="low">🟢 منخفضة</option> <option value="medium">🟡 متوسطة</option> <option value="high">🔴 عالية</option> </select> <button type="submit" [disabled]="todoForm.invalid" class="btn-primary"> ➕ إضافة </button> </div> <small *ngIf="title?.invalid && title?.touched" class="error"> ❌ لازم 3 أحرف على الأقل </small> </form>

6.2 TodoItem Component - عنصر مهمة واحدة

// components/todo-item/todo-item.component.ts import { Component, Input } from '@angular/core'; import { Todo } from '../../models/todo'; import { TodoService } from '../../services/todo.service'; @Component({ selector: 'app-todo-item', templateUrl: './todo-item.component.html' }) export class TodoItemComponent { @Input() todo!: Todo; constructor(private todoService: TodoService) {} toggle() { this.todoService.toggleTodo(this.todo.id); } delete() { this.todoService.deleteTodo(this.todo.id); } getPriorityColor(): string { const colors = { low: '#4caf50', medium: '#ff9800', high: '#f44336' }; return colors[this.todo.priority]; } }
<!-- todo-item.component.html --> <div class="todo-item" [ngClass]="{ completed: todo.completed }"> <input type="checkbox" [checked]="todo.completed" (change)="toggle()" > <span class="priority-badge" [style.backgroundColor]="getPriorityColor()"> {{ todo.priority }} </span> <span class="todo-title">{{ todo.title }}</span> <span class="todo-date"> {{ todo.createdAt | date:'short' }} </span> <button (click)="delete()" class="btn-delete">🗑️</button> </div>

6.3 TodoStats Component - إحصائيات

// components/todo-stats/todo-stats.component.ts import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { TodoService } from '../../services/todo.service'; @Component({ selector: 'app-todo-stats', templateUrl: './todo-stats.component.html' }) export class TodoStatsComponent implements OnInit { total$: Observable<number>; completed$: Observable<number>; active$: Observable<number>; constructor(private todoService: TodoService) { this.total$ = this.todoService.todos$.pipe(map(todos => todos.length)); this.completed$ = this.todoService.todos$.pipe(map(todos => todos.filter(t => t.completed).length)); this.active$ = this.todoService.todos$.pipe(map(todos => todos.filter(t => !t.completed).length)); } ngOnInit() {} clearCompleted() { this.todoService.clearCompleted(); } }
<!-- todo-stats.component.html --> <div class="todo-stats"> <div class="stat"> <span class="number">{{ total$ | async }}</span> <span class="label">المجموع</span> </div> <div class="stat"> <span class="number">{{ active$ | async }}</span> <span class="label">متبقية</span> </div> <div class="stat"> <span class="number">{{ completed$ | async }}</span> <span class="label">منجزة</span> </div> <button (click)="clearCompleted()" class="btn-secondary"> 🧹 حذف المنجزة </button> </div>

6.4 TodoList Component - المكون الرئيسي

// components/todo-list/todo-list.component.ts import { Component, OnInit } from '@angular/core'; import { Observable, combineLatest } from 'rxjs'; import { map } from 'rxjs/operators'; import { Todo } from '../../models/todo'; import { TodoService } from '../../services/todo.service'; @Component({ selector: 'app-todo-list', templateUrl: './todo-list.component.html' }) export class TodoListComponent implements OnInit { filteredTodos$: Observable<Todo[]>; currentFilter = 'all'; constructor(private todoService: TodoService) { // ✅ combineLatest: بيجمع أكتر من Observable this.filteredTodos$ = combineLatest([ this.todoService.todos$, this.todoService.filter$ ]).pipe( map(([todos, filter]) => { switch (filter) { case 'active': return todos.filter(t => !t.completed); case 'completed': return todos.filter(t => t.completed); default: return todos; } }) ); } ngOnInit() {} setFilter(filter: 'all' | 'active' | 'completed') { this.currentFilter = filter; this.todoService.setFilter(filter); } }
<!-- todo-list.component.html --> <div class="todo-app"> <h1>✅ قائمة المهام</h1> <app-todo-form></app-todo-form> <app-todo-stats></app-todo-stats> <!-- أزرار الفلترة --> <div class="filters"> <button (click)="setFilter('all')" [class.active]="currentFilter === 'all'" >الكل</button> <button (click)="setFilter('active')" [class.active]="currentFilter === 'active'" >متبقية</button> <button (click)="setFilter('completed')" [class.active]="currentFilter === 'completed'" >منجزة</button> </div> <!-- قائمة المهام --> <div class="todo-list"> <app-todo-item *ngFor="let todo of filteredTodos$ | async" [todo]="todo"> </app-todo-item> <div *ngIf="(filteredTodos$ | async)?.length === 0" class="empty"> 📭 لا توجد مهام </div> </div> </div>

🏠 الخطوة 7: تجميع كل شيء في AppComponent

// app.component.html <div class="app-container"> <app-todo-list></app-todo-list> </div>
// app.module.ts - تسجيل كل المكونات والـ Modules import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { ReactiveFormsModule } from '@angular/forms'; import { AppComponent } from './app.component'; import { TodoListComponent } from './components/todo-list/todo-list.component'; import { TodoItemComponent } from './components/todo-item/todo-item.component'; import { TodoFormComponent } from './components/todo-form/todo-form.component'; import { TodoStatsComponent } from './components/todo-stats/todo-stats.component'; @NgModule({ declarations: [ AppComponent, TodoListComponent, TodoItemComponent, TodoFormComponent, TodoStatsComponent ], imports: [ BrowserModule, ReactiveFormsModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }

🎯 ما تعلمته من هذا المشروع

🔄 مقارنة سريعة: React vs Angular في نفس المشروع

الجانب React (الدرس 5) Angular (الدرس 11)
State Management useState BehaviorSubject (RxJS)
Forms Controlled Components يدوي ReactiveFormsModule
Validation منطق يدوي Validators جاهزة
Data Flow Props down, Events up @Input, @Output, Services

📚 دروس ذات صلة

❓ أسئلة شائعة

ليه استخدمنا BehaviorSubject مش Subject عادي؟

BehaviorSubject بيحفظ آخر قيمة ويبعتها لأي مشترك جديد. يعني لو مكون اشترك بعد ما البيانات اتحملت، هياخد آخر قيمة على طول. Subject عادي مش بيعمل كده - المشترك الجديد هيفضل مستنى الحدث الجاي.

هل localStorage آمن لحفظ البيانات؟

لا للبيانات الحساسة (كلمات مرور، بيانات بنكية). أي حد يقدر يفتح DevTools ويقرأ localStorage. لكن للبيانات العامة زي قائمة المهام، هو كافي. للبيانات الحساسة، استخدم Backend + Database.

إزاي أضيف Backend حقيقي للمشروع ده؟

استخدم Node.js + Express + MongoDB. غير الـ TodoService عشان يستخدم HttpClient بدل localStorage. أضف Endpoints: GET /todos, POST /todos, PUT /todos/:id, DELETE /todos/:id. المشروع هيبقى Full Stack حقيقي!

← الدرس السابق