الآن سنبني بحث احترافي كامل متوافق مع آخر نسخة من مشروعك:
✅ Clean Architecture
✅ Cubit
✅ Supabase Search
✅ Debounce
✅ SearchBar احترافي
✅ Loading
✅ إعادة النتائج
✅ البحث بالعنوان والمحتوى
✅ شرح كل ملف بالتفصيل
🧭 الفلو الكامل
User يكتب
↓
TextField.onChanged
↓
Cubit.searchNotes()
↓
Debounce
↓
UseCase
↓
Repository
↓
DataSource
↓
Supabase Query
↓
NotesLoaded(results)
↓
UI يتحدثJSX🧱 1) DataSource
📁 notes_remote_datasource.dart
📄 الكود الكامل
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/note_model.dart';
class NotesRemoteDatasource {
final SupabaseClient client;
NotesRemoteDatasource(this.client);
/// 🔥 جلب كل الملاحظات
Future<List<NoteModel>> getNotes() async {
final response = await client
.from('notes')
.select();
return (response as List)
.map((e) => NoteModel.fromJson(e))
.toList();
}
/// 🔥 البحث داخل Supabase
Future<List<NoteModel>> searchNotes(String query) async {
final response = await client
.from('notes')
/// select = جلب البيانات
.select()
/// البحث في title OR content
.or(
'title.ilike.%$query%,content.ilike.%$query%',
);
/// تحويل JSON → NoteModel
return (response as List)
.map((e) => NoteModel.fromJson(e))
.toList();
}
}
Future<List<NoteModel>> searchNotes(String query) async {
if (query.trim().isEmpty) return [];
final response = await client
.from('notes')
.select()
.or(
'title.ilike.%$query%,content.ilike.%$query%',
);
return (response as List)
.map((e) => NoteModel.fromJson(e))
.toList();
}JSX🧠 شرح الملف بالتفصيل
أولاً: ما وظيفة هذا الـ Method ؟
هذا الميثود يقوم بـ:
- استقبال كلمة بحث من المستخدم
- إرسال Query إلى Supabase
- البحث داخل:
- title
- content
- جلب النتائج
- تحويل JSON القادم من Supabase إلى Objects من نوع
NoteModel - إرجاع List تحتوي الملاحظات المطابقة
شرح الترويسة (Function Signature)
Future<List<NoteModel>> searchNotes(String query)JSXنفككها:
1️⃣ Future
يعني العملية غير فورية وتحتاج وقت.
لماذا؟
لأننا نتصل بالسيرفر (Supabase).
أي عملية:
- API
- Database
- Internet
تكون Async.
2️⃣ List<NoteModel>
يعني النتيجة النهائية ستكون:
[
NoteModel(),
NoteModel(),
NoteModel(),
]JSXأي قائمة ملاحظات.
3️⃣ searchNotes
اسم الدالة.
4️⃣ String query
القيمة التي يكتبها المستخدم داخل البحث.
مثلاً:
searchNotes("flutter")JSXهنا:
- query = flutter
الآن داخل الدالة
أول سطر
final response = await clientJSXماذا يعني؟
client
هو كائن Supabase Client.
غالباً يكون:
final client = Supabase.instance.client;JSXهذا الكائن مسؤول عن:
- الاتصال بقاعدة البيانات
- تنفيذ Queries
- Authentication
- Storage
await
يعني:
انتظر حتى يرجع السيرفر النتيجة.
لأن الاتصال بالسيرفر يحتاج وقت.
السطر التالي
.from('notes')JSXيعني:
اذهب إلى جدول اسمه notes
يشبه SQL:
FROM notesJSXالسطر التالي
.select()JSXيعني:
اجلب البيانات
يشبه SQL:
SELECT *<br>FROM notesJSXلماذا لم نكتب أسماء الأعمدة؟
لأن:
.select()JSXبدون Parameters تعني:
SELECT *JSXأي جميع الأعمدة.
يمكن تحديد أعمدة
مثلاً:
.select('id,title')JSXيعني:
SELECT id, titleJSXأهم جزء
.or(
'title.ilike.%$query%,content.ilike.%$query%',
)JSXهذا هو البحث الحقيقي.
ماذا تفعل or() ؟
تعني:
ابحث إذا تحقق أحد الشرطين.
مثل SQL:
WHERE title ILIKE '%flutter%'
OR content ILIKE '%flutter%'JSXنفكك النص
'title.ilike.%$query%'JSXلو:
query = flutterJSXتصبح:
title.ilike.%flutter%JSXما معنى ilike ؟
في PostgreSQL:
- like → حساس للأحرف الكبيرة
- ilike → غير حساس
مثال:
| النص | يطابق flutter ؟ |
|---|---|
| Flutter | نعم |
| FLUTTER | نعم |
| flutter | نعم |
ما معنى % ؟
في SQL:
%JSXتعني:
أي شيء
مثال
%flutter%JSXتعني:
- flutter course
- تعلم flutter
- flutter123
- abcflutterxyz
أي شيء يحتوي الكلمة.
الشرط الثاني
content.ilike.%$query%JSXيعني:
ابحث أيضاً داخل المحتوى.
النتيجة النهائية للـ Query
لو المستخدم كتب:
flutterJSXسيصبح SQL تقريباً:
SELECT *
FROM notes
WHERE title ILIKE '%flutter%'
OR content ILIKE '%flutter%'JSXماذا يعيد Supabase ؟
يرجع JSON List.
مثال:
[
{
"id": 1,
"title": "Flutter",
"content": "Bloc tutorial"
},
{
"id": 2,
"title": "Dart",
"content": "Flutter basics"
}
]
الآن هذا الجزء
return (response as List)JSXلماذا as List ؟
لأن response نوعه dynamic.
فنحن نقول لـ Dart:
اعتبره List
الآن map()
.map((e) => NoteModel.fromJson(e))JSXهذا أهم جزء بالتحويل.
ماذا تفعل map ؟
تمر على كل عنصر داخل القائمة.
مثلاً:
[
json1,
json2,
json3
]JSXوتحول كل عنصر.
e ماذا يمثل؟
يمثل عنصر واحد من الـ JSON.
مثلاً:
{
"id": 1,
"title": "Flutter",
"content": "Bloc"
}JSXfromJson
تحول JSON → Object
مثال
NoteModel.fromJson(e)JSXتصنع:
NoteModel(
id: 1,
title: "Flutter",
content: "Bloc",
)JSXلماذا نحتاج Model ؟
لأن التعامل مع Objects أسهل من JSON.
بدلاً من:
note['title']JSXنستخدم:
note.titleJSXأخيراً
.toList();JSXلأن map يرجع:
IterableJSXوليس List.
فنحوّله إلى List.
الشكل النهائي للرحلة
User يكتب كلمة
↓
searchNotes(query)
↓
Supabase Query
↓
جلب JSON
↓
تحويل JSON → NoteModel
↓
إرجاع List<NoteModel>
كيف تستخدم داخل Cubit ؟
غالباً:
final notes = await repository.searchNotes(query);
emit(SearchSuccess(notes));JSXملاحظة مهمة جداً ⚠️
هذا الكود معرض لمشكلة إذا كتب المستخدم:
%JSXأو رموز SQL خاصة.
الأفضل تنظيف query قبل الإرسال.
نسخة احترافية أكثر
Future<List<NoteModel>> searchNotes(String query) async {
if (query.trim().isEmpty) return [];
final response = await client
.from('notes')
.select()
.or(
'title.ilike.%$query%,content.ilike.%$query%',
);
return (response as List)
.map((e) => NoteModel.fromJson(e))
.toList();
}JSXماذا يحدث في الذاكرة أثناء التنفيذ؟
response
↓
List<Map<String,dynamic>>
↓ map()
NoteModel
NoteModel
NoteModel
↓ toList()
List<NoteModel>
الفرق بين map و forEach هنا
map
للتحويل.
json → object
forEach
للتنفيذ فقط.
مثل:
print()
ولا يعيد List جديدة.
لماذا استخدمنا OR وليس AND ؟
لأننا نريد:
- العنوان يحتوي الكلمة
أو - المحتوى يحتوي الكلمة
🔹 .from(‘notes’)
تحديد الجدولJSX🔹 select()
جلب البياناتJSX🔹 or()
بحث في أكثر من حقلJSX🔹 ilike
بحث غير حساس للأحرفJSX🔹 %query%
يعني يحتوي على النصJSX🧱 2) Repository Interface
📁 notes_repository.dart
📄 الكود
import '../entities/note.dart';
abstract class NotesRepository {
Future<List<Note>> getNotes();
/// 🔥 البحث
Future<List<Note>> searchNotes(String query);
}JSX🧠 شرح
Domain يعرف العملية فقط
ولا يعرف SupabaseJSX🧱 3) RepositoryImpl
📁 notes_repository_impl.dart
📄 الكود
import '../../domain/entities/note.dart';
import '../../domain/repository/notes_repository.dart';
import '../datasource/notes_remote_datasource.dart';
class NotesRepositoryImpl
implements NotesRepository {
final NotesRemoteDatasource remote;
NotesRepositoryImpl(this.remote);
@override
Future<List<Note>> getNotes() async {
return await remote.getNotes();
}
/// 🔥 البحث
@override
Future<List<Note>> searchNotes(
String query,
) async {
return await remote.searchNotes(query);
}
}JSX🧠 شرح
يمرر الطلب من Domain → DataJSX🧱 4) UseCase
📁 search_notes_usecase.dart
📄 الكود
import '../entities/note.dart';
import '../repository/notes_repository.dart';
class SearchNotesUsecase {
final NotesRepository repo;
SearchNotesUsecase(this.repo);
/// call()
Future<List<Note>> call(
String query,
) async {
return await repo.searchNotes(query);
}
}JSX🧠 شرح
يمثل عملية البحثJSX🧱 5) Cubit
📁 notes_cubit.dart
📄 الكود الكامل
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/usecases/get_notes_usecases.dart';
import '../../domain/usecases/search_notes_usecase.dart';
import 'notes_state.dart';
class NotesCubit extends Cubit<NotesState> {
final GetNotesUsecases getNotes;
final SearchNotesUsecase searchUsecase;
NotesCubit(
this.getNotes,
this.searchUsecase,
) : super(NotesInitial());
/// debounce timer
Timer? debounce;
/// 🔥 جلب البيانات
Future<void> fetchNotes() async {
emit(NotesLoading());
try {
final notes = await getNotes();
emit(NotesLoaded(notes));
} catch (e) {
emit(
NotesError(e.toString()),
);
}
}
/// 🔥 البحث
Future<void> searchNotes(
String query,
) async {
/// إلغاء الطلب السابق
debounce?.cancel();
debounce = Timer(
const Duration(milliseconds: 500),
() async {
/// إذا الحقل فارغ
if (query.isEmpty) {
fetchNotes();
return;
}
emit(NotesLoading());
try {
final results =
await searchUsecase(query);
emit(
NotesLoaded(results),
);
} catch (e) {
emit(
NotesError(e.toString()),
);
}
},
);
}
@override
Future<void> close() {
debounce?.cancel();
return super.close();
}
}JSX🧠 شرح Cubit بالتفصيل
🔹 Timer
يؤخر البحثJSX🔹 debounce?.cancel()
إلغاء البحث السابقJSX🔹 Duration(500ms)
انتظار المستخدم ينتهي من الكتابةJSX🔹 query.isEmpty
إذا حذف المستخدم النص
→ نرجع كل الملاحظاتJSX🔹 close()
تنظيف الذاكرةJSX🧱 6) Main.dart
📄 أضف:
final searchNotes =
SearchNotesUsecase(repo);JSX📄 مرره:
runApp(
MyApp(
getNotes,
searchNotes,
),
);JSX📄 داخل MyApp
final SearchNotesUsecase searchNotes;JSX📄 داخل BlocProvider
create: (_) => NotesCubit(
getNotes,
searchNotes,
)..fetchNotes(),JSX🧱 7) UI
📁 notes_page.dart
📄 الكود الكامل للبحث
ضعه فوق Grid مباشرة:
Column(
children: [
/// 🔥 Search Field
Padding(
padding: const EdgeInsets.all(15),
child: TextField(
decoration: InputDecoration(
hintText: "ابحث عن ملاحظة...",
prefixIcon: const Icon(
Icons.search,
),
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(15),
),
),
/// عند الكتابة
onChanged: (value) {
context
.read<NotesCubit>()
.searchNotes(value);
},
),
),
/// القائمة
Expanded(
child: BlocBuilder<
NotesCubit,
NotesState>(
builder: (context, state) {
if (state is NotesLoading) {
return const Center(
child:
CircularProgressIndicator(),
);
}
if (state is NotesError) {
return Center(
child: Text(state.message),
);
}
if (state is NotesLoaded) {
if (state.notes.isEmpty) {
return const Center(
child: Text(
"لا توجد نتائج",
),
);
}
return GridView.builder(
padding:
const EdgeInsets.all(15),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1,
),
itemCount:
state.notes.length,
itemBuilder:
(context, index) {
final note =
state.notes[index];
final color =
noteColors[
index %
noteColors.length
];
return NoteCard(
note: note,
color: color,
);
},
);
}
return const SizedBox();
},
),
),
],
)JSX🧠 ماذا يحدث الآن؟
المستخدم يكتب
↓
onChanged
↓
Cubit.searchNotes()
↓
Debounce
↓
Supabase Query
↓
NotesLoaded(results)
↓
GridView يتحدثJSX💥 النتيجة النهائية
✅ بحث احترافي
✅ سريع
✅ لا يرسل request لكل حرف
✅ يعمل على Supabase مباشرة
✅ Clean Architecture صحيح
🧠 الجملة الذهبية
Professional search is server-driven + debouncedJSXسنقوم بـ:
✔ إضافة Search TextField
✔ ربطه مع Cubit
✔ الحفاظ على BlocBuilder
✔ الحفاظ على BlocListener
✔ عدم تخريب الهيكل الحاليJSX🧠 الفكرة المهمة جداً
حالياً body عندك:
body: BlocBuilder(...)JSX❌ هذا لا يسمح بإضافة SearchBar فوق Grid بسهولة
لأن:
BlocBuilder يعيد Widget واحدة فقطJSX✅ لذلك سنحول body إلى:
ColumnJSX🎯 الشكل النهائي
Column
├── SearchField
└── Expanded
└── BlocBuilderJSX🧱 لماذا Expanded؟
لأن:
GridView داخل Column
يحتاج مساحة محددةJSX📄 الملف الكامل بعد التعديل + شرح داخلي
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:notes_app/features/notes/presentation/pages/add_note_page.dart';
import 'package:notes_app/features/notes/presentation/widgets/note_card.dart';
import '../cubit/notes_cubit.dart';
import '../cubit/notes_state.dart';
class Notes_page extends StatefulWidget {
const Notes_page({super.key});
@override
State<Notes_page> createState() => _Notes_pageState();
}
class _Notes_pageState extends State<Notes_page> {
@override
Widget build(BuildContext context) {
return BlocListener<NotesCubit, NotesState>(
/// 🔥 الاستماع للحالات
listener: (context, state) {
/// إضافة
if (state is NoteAdded) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("تمت إضافة الملاحظة"),
backgroundColor: Colors.green,
),
);
}
/// تعديل
if (state is NoteUpdated) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("تم تعديل الملاحظة"),
backgroundColor: Colors.green,
),
);
}
/// حذف
if (state is NoteDeleted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("تم حذف الملاحظة"),
backgroundColor: Colors.red,
),
);
}
},
child: Scaffold(
/// 🔥 زر الإضافة
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AddNotePage(),
),
);
},
child: Icon(Icons.add),
),
appBar: AppBar(
title: Text("صفحة الملاحظات"),
),
/// 🔥 body أصبح Column
body: Column(
children: [
/// =========================
/// 🔥 Search Field
/// =========================
Padding(
padding: const EdgeInsets.all(15),
child: TextField(
decoration: InputDecoration(
hintText: "ابحث عن ملاحظة...",
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(15),
),
),
/// 🔥 عند الكتابة
onChanged: (value) {
context
.read<NotesCubit>()
.searchNotes(value);
},
),
),
/// =========================
/// 🔥 Expanded مهم جداً
/// =========================
Expanded(
child: BlocBuilder<
NotesCubit,
NotesState>(
builder: (context, state) {
/// Loading
if (state is NotesLoading) {
return Center(
child:
CircularProgressIndicator(),
);
}
/// Error
if (state is NotesError) {
return Center(
child: Text(state.message),
);
}
/// Success
if (state is NotesLoaded) {
/// لا توجد بيانات
if (state.notes.isEmpty) {
return Center(
child:
Text("لا توجد نتائج"),
);
}
return Padding(
padding:
const EdgeInsets.all(15),
child: RefreshIndicator(
onRefresh: () async {
await context
.read<NotesCubit>()
.fetchNotes();
},
child: GridView.builder(
physics:
AlwaysScrollableScrollPhysics(),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 1,
),
itemCount:
state.notes.length,
itemBuilder:
(context, index) {
final note =
state.notes[index];
/// 🔥 ألوان الكاردات
final color =
noteColors[
index %
noteColors.length
];
return NoteCard(
note: note,
color: color,
);
},
),
),
);
}
return SizedBox();
},
),
),
],
),
),
);
}
}JSX🧠 شرح أهم نقطة
🔥 لماذا وضعنا Search خارج BlocBuilder؟
لأن:
SearchBar لا يعتمد على stateJSXبينما:
GridView يعتمد على stateJSX🧠 ماذا يحدث الآن؟
المستخدم يكتب
↓
onChanged
↓
Cubit.searchNotes()
↓
Supabase Query
↓
emit(NotesLoaded)
↓
BlocBuilder
↓
GridView يتحدثJSX⚠️ مهم جداً
لا تنس إضافة:
searchNotes()JSXداخل Cubit
⚠️ وأيضاً:
لا تنس تمرير:
SearchNotesUsecaseJSXفي main.dart
💥 لماذا هذا التصميم احترافي؟
| الجزء | المسؤولية |
|---|---|
| TextField | إدخال المستخدم |
| Cubit | إدارة البحث |
| UseCase | عملية البحث |
| Repository | abstraction |
| DataSource | Supabase |
| BlocBuilder | تحديث UI |
🧠 الجملة الذهبية
Search input stays outside BlocBuilder, results stay insideJSX