إنشاء تطبيق ملاحظات باستخدام Flutter
تطبيق الملاحظات من أكثر التطبيقات المناسبة لتعلّم Flutter لأن فكرته بسيطة، لكن داخله يجمع أهم أساسيات بناء التطبيقات: إدخال البيانات، عرض القوائم، إدارة الحالة، التحقق من المدخلات، والتعامل مع التعديل والحذف. وميزة Flutter هنا أنه يعتمد على مفهوم الـ widgets بشكل أساسي، حيث تُبنى الواجهة من شجرة widgets، وتُعاد إعادة بناء الأجزاء التي تتغير عندما تتغير الحالة. كما أن Flutter يميز بين StatelessWidget وStatefulWidget، ويستخدم setState لتحديث الواجهة عند تغير البيانات داخل الحالة.
في هذا المقال سنبني تطبيق ملاحظات كاملًا بطريقة سهلة ومفهومة، ثم نطوره خطوة خطوة حتى يصبح مشروعًا عمليًا يمكنك الاعتماد عليه كنقطة بداية لأي تطبيق إنتاجي. سنركز على النسخة الأساسية أولًا، ثم نضيف تحسينات مثل البحث، التعديل، الحذف، والتحقق من الإدخال. وسنستخدم في الشرح مكونات Flutter الأساسية مثل TextField وTextEditingController وListView.builder لأنها من الأدوات المناسبة جدًا لبناء هذا النوع من التطبيقات. Flutter يوصي باستخدام TextEditingController عندما تحتاج إلى قراءة النص الذي أدخله المستخدم، كما يوصي بـ ListView.builder عندما تكون القائمة قابلة للنمو أو تحتوي عناصر كثيرة.
فكرة التطبيق قبل كتابة الكود
تطبيق الملاحظات عادة يحتاج إلى ثلاث وظائف رئيسية: إضافة ملاحظة جديدة، عرض جميع الملاحظات، والتعامل مع التعديل أو الحذف. ويمكنك لاحقًا إضافة تحسينات مثل البحث، تصنيف الملاحظات، تثبيت ملاحظة معينة في الأعلى، أو حفظ البيانات محليًا حتى لا تضيع عند إغلاق التطبيق. لكن البداية الأفضل هي بناء نواة نظيفة ومفهومة، لأن أي تعقيد مبكر يجعل المشروع أصعب من اللازم.
في هذا المقال سنبني نسخة تعتمد على قائمة داخلية في الذاكرة حتى نفهم الفكرة بوضوح، ثم يمكنك بعد ذلك نقل البيانات إلى قاعدة محلية أو خدمة سحابية. هذا الأسلوب ممتاز للتعلّم لأنه يركز على منطق التطبيق نفسه بدل أن يشتتك بين أكثر من طبقة في البداية.
إنشاء مشروع Flutter جديد
ابدأ بإنشاء مشروع جديد عبر الأمر التالي:
flutter create notes_app
بعدها افتح المشروع داخل VS Code أو Android Studio، ثم انتقل إلى ملف lib/main.dart. هذا الملف سيكون نقطة الانطلاق. سنبني فيه واجهة رئيسية تحتوي على قائمة الملاحظات وزر لإضافة ملاحظة جديدة.
بنية البيانات الخاصة بالملاحظة
قبل بناء الواجهة، من الأفضل أن نحدد شكل الملاحظة نفسها. في تطبيق حقيقي قد تحتاج إلى id، عنوان، نص، تاريخ إنشاء، وربما لون أو تصنيف. لكن لأجل البساطة سنبدأ ببنية واضحة:
class Note {
final String id;
String title;
String content;
final DateTime createdAt;
Note({
required this.id,
required this.title,
required this.content,
required this.createdAt,
});
}
هذا النموذج بسيط، لكنه كافٍ جدًا للنسخة الأولى. وجود id مهم لاحقًا عندما تريد التعديل أو الحذف أو الترتيب. أما title وcontent فهما حقلا الإدخال الأساسيان في أغلب تطبيقات الملاحظات. وcreatedAt يساعدك على عرض تاريخ الإنشاء أو فرز الملاحظات.
بناء الواجهة الأساسية
سنستخدم MaterialApp وScaffold وAppBar وListView.builder لعرض الملاحظات. هذا يتماشى مع أسلوب Flutter في بناء الواجهات من widgets صغيرة قابلة للتركيب. وعندما تحتاج إلى قائمة قابلة للتوسع، فإن ListView.builder هو الخيار المناسب لأنه يبني العناصر عند الحاجة بدل إنشاء كل العناصر دفعة واحدة.
مثال كامل لواجهة التطبيق
import 'package:flutter/material.dart';
void main() {
runApp(const NotesApp());
}
class Note {
final String id;
String title;
String content;
final DateTime createdAt;
Note({
required this.id,
required this.title,
required this.content,
required this.createdAt,
});
}
class NotesApp extends StatelessWidget {
const NotesApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Notes App',
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: Colors.blue,
),
home: const NotesHomePage(),
);
}
}
class NotesHomePage extends StatefulWidget {
const NotesHomePage({super.key});
@override
State<NotesHomePage> createState() => _NotesHomePageState();
}
class _NotesHomePageState extends State<NotesHomePage> {
final List<Note> _notes = [
Note(
id: '1',
title: 'أول ملاحظة',
content: 'هذه مجرد ملاحظة تجريبية داخل التطبيق.',
createdAt: DateTime.now(),
),
];
final TextEditingController _searchController = TextEditingController();
List<Note> get _filteredNotes {
final query = _searchController.text.trim().toLowerCase();
if (query.isEmpty) return _notes;
return _notes.where((note) {
return note.title.toLowerCase().contains(query) ||
note.content.toLowerCase().contains(query);
}).toList();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _openNoteDialog({Note? note, int? index}) {
final titleController = TextEditingController(text: note?.title ?? '');
final contentController = TextEditingController(text: note?.content ?? '');
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(note == null ? 'إضافة ملاحظة' : 'تعديل الملاحظة'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'عنوان الملاحظة',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: contentController,
maxLines: 5,
decoration: const InputDecoration(
labelText: 'محتوى الملاحظة',
border: OutlineInputBorder(),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('إلغاء'),
),
ElevatedButton(
onPressed: () {
final title = titleController.text.trim();
final content = contentController.text.trim();
if (title.isEmpty || content.isEmpty) {
return;
}
setState(() {
if (note == null) {
_notes.insert(
0,
Note(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
content: content,
createdAt: DateTime.now(),
),
);
} else if (index != null) {
_notes[index].title = title;
_notes[index].content = content;
}
});
Navigator.pop(context);
},
child: const Text('حفظ'),
),
],
);
},
);
}
void _deleteNote(int index) {
setState(() {
_notes.removeAt(index);
});
}
@override
Widget build(BuildContext context) {
final notes = _filteredNotes;
return Scaffold(
appBar: AppBar(
title: const Text('تطبيق الملاحظات'),
),
floatingActionButton: FloatingActionButton(
onPressed: () => _openNoteDialog(),
child: const Icon(Icons.add),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
hintText: 'ابحث في الملاحظات...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Expanded(
child: notes.isEmpty
? const Center(
child: Text('لا توجد ملاحظات بعد'),
)
: ListView.builder(
itemCount: notes.length,
itemBuilder: (context, index) {
final note = notes[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
title: Text(note.title),
subtitle: Text(
note.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () => _openNoteDialog(note: note, index: _notes.indexOf(note)),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _deleteNote(_notes.indexOf(note)),
),
),
);
},
),
),
],
),
),
);
}
}
هذا المثال يعطيك هيكلًا عمليًا واضحًا: قائمة ملاحظات داخل الذاكرة، مربع بحث، إضافة ملاحظة عبر نافذة حوار، وتعديل أو حذف مباشر من القائمة. واعتمدنا على StatefulWidget لأن التطبيق يحتاج إلى حالة متغيرة، وهو بالضبط السيناريو الذي صُمم من أجله هذا النوع من widgets. كما استخدمنا TextEditingController لقراءة النصوص من الحقول، وهو الأسلوب الذي توصي به Flutter عند التعامل مع الإدخال.
لماذا استخدمنا StatefulWidget هنا؟
لأن تطبيق الملاحظات ليس صفحة ثابتة. المستخدم يضيف ملاحظات، يعدلها، يحذفها، ويبحث داخلها، وبالتالي البيانات تتغير باستمرار. Flutter يوضح أن StatefulWidget مناسب عندما تحتاج الواجهة إلى إعادة البناء بسبب تغيّر الحالة، وأن setState هو الطريقة التي تخبر بها Flutter بأن الحالة تغيّرت وأن الواجهة تحتاج إلى تحديث.
في المثال السابق، كل مرة نضيف أو نحذف أو نعدل ملاحظة، نستدعي setState حتى تنعكس التغييرات فورًا على الشاشة. هذا هو أساس العمل داخل Flutter: البيانات تتغير، ثم يعاد بناء الجزء المتأثر من الواجهة.
التعامل مع الإدخال بطريقة صحيحة
Flutter يوفر TextField وTextFormField لإدخال النصوص. TextField مناسب جدًا عندما تريد حقلًا مباشرًا وبسيطًا، بينما TextFormField يكون مفيدًا أكثر عندما تريد التحقق من صحة المدخلات داخل Form. Flutter يذكر أن TextFormField يمكنه عرض أخطاء التحقق، وأن TextEditingController هو الطريقة المناسبة لقراءة قيمة النص والتحكم بها، مع ضرورة استدعاء dispose عند الانتهاء منه حتى تتحرر الموارد.
مثال على التحقق من المدخلات
final _formKey = GlobalKey<FormState>();
final titleController = TextEditingController();
final contentController = TextEditingController();
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: titleController,
decoration: const InputDecoration(labelText: 'العنوان'),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'يرجى إدخال عنوان';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: contentController,
maxLines: 4,
decoration: const InputDecoration(labelText: 'المحتوى'),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'يرجى إدخال المحتوى';
}
return null;
},
),
],
),
);
هذا الأسلوب أفضل من قبول أي نص فارغ، لأنه يمنع إنشاء ملاحظات ناقصة أو غير مفيدة. وفي التطبيقات الحقيقية، التحقق من المدخلات ليس رفاهية، بل جزء مهم من جودة التجربة.
تحسين شكل الملاحظات
النسخة الأولى من التطبيق تؤدي الغرض، لكن المظهر مهم جدًا أيضًا. يمكنك جعل كل ملاحظة تظهر داخل Card مع عنوان واضح ومحتوى مختصر وتاريخ إنشاء. يمكنك أيضًا إضافة لون مختلف لكل ملاحظة، أو استخدام أيقونة مميزة، أو إظهار شارة صغيرة لتمييز الملاحظات المهمة. لأن التطبيق يعتمد على واجهة قائمة على widgets، فكل تحسين بصري بسيط يمكن إضافته بسهولة داخل Card أو ListTile.
مثال بطاقة ملاحظة أجمل
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: ListTile(
contentPadding: const EdgeInsets.all(16),
title: Text(
note.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
note.content,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
),
);
بهذه الطريقة يصبح التطبيق أكثر حيوية، وليس مجرد قائمة نصية جافة.
إضافة التعديل على الملاحظة
التعديل مهم جدًا لأن المستخدم لا يريد إنشاء ملاحظة جديدة كل مرة يكتشف خطأ صغيرًا. في المثال السابق فتحنا نافذة حوار مع القيم الحالية للملاحظة، ثم عدلنا البيانات داخل القائمة. هذا النمط عملي وبسيط. الفكرة الأساسية هي أن تمرر الملاحظة المراد تعديلها مع مؤشرها، ثم تملأ الحقول بالقيم القديمة، وبعد الحفظ تعيد استبدال البيانات داخل القائمة.
void _editNote(Note note, int index) {
final titleController = TextEditingController(text: note.title);
final contentController = TextEditingController(text: note.content);
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('تعديل الملاحظة'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: titleController),
const SizedBox(height: 12),
TextField(controller: contentController, maxLines: 4),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('إلغاء'),
),
ElevatedButton(
onPressed: () {
setState(() {
_notes[index].title = titleController.text;
_notes[index].content = contentController.text;
});
Navigator.pop(context);
},
child: const Text('حفظ'),
),
],
);
},
);
}
يمكنك لاحقًا نقل هذا المنطق إلى شاشة مستقلة بدل AlertDialog إذا أردت تجربة أكثر احترافية.
إضافة الحذف مع تأكيد
الحذف أمر حساس، لأن المستخدم قد يضغط عليه بالخطأ. لذلك الأفضل أن تعرض رسالة تأكيد أولًا. هذا يجعل التطبيق أكثر نضجًا ويمنع ضياع البيانات بشكل غير مقصود.
void _confirmDelete(int index) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('حذف الملاحظة'),
content: const Text('هل أنت متأكد أنك تريد حذف هذه الملاحظة؟'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('إلغاء'),
),
ElevatedButton(
onPressed: () {
setState(() {
_notes.removeAt(index);
});
Navigator.pop(context);
},
child: const Text('حذف'),
),
],
);
},
);
}
هذا النوع من التفاصيل يرفع جودة التطبيق بشكل ملحوظ، حتى لو كان الكود نفسه بسيطًا.
إضافة البحث داخل الملاحظات
البحث ميزة مهمة جدًا في تطبيقات الملاحظات لأن عدد العناصر قد يكبر سريعًا. في الكود السابق استخدمنا TextEditingController لحقل البحث، ثم فلترنا القائمة بناءً على العنوان أو المحتوى. هذه طريقة مباشرة وسهلة، وتكفي لتطبيق بسيط أو متوسط الحجم.
List<Note> get filteredNotes {
final query = _searchController.text.toLowerCase().trim();
if (query.isEmpty) return _notes;
return _notes.where((note) {
return note.title.toLowerCase().contains(query) ||
note.content.toLowerCase().contains(query);
}).toList();
}
هذه الفكرة تجعل تجربة المستخدم أفضل بكثير. بدل التمرير بين عشرات أو مئات الملاحظات، يستطيع الوصول إلى ما يريد بسرعة.
كيف تجعل التطبيق أكثر احترافية؟
بعد الانتهاء من النسخة الأساسية، يمكنك تطوير التطبيق في عدة اتجاهات. مثلًا يمكنك إضافة:
تصنيف الملاحظات إلى شخصية، عمل، دراسة.
إضافة لون لكل ملاحظة.
إظهار تاريخ الإنشاء بصيغة جميلة.
حفظ الملاحظات محليًا حتى لا تضيع بعد إغلاق التطبيق.
إضافة شاشة تفاصيل للملاحظة بدل عرضها في القائمة فقط.
دعم الوضع الداكن.
هذه التحسينات لا تغيّر الفكرة الأساسية، لكنها تجعل التطبيق أقرب إلى منتج حقيقي. وأجمل ما في Flutter أنه يسمح لك بتجربة هذه التغييرات بسرعة لأن الواجهة مرنة ومبنية على widgets قابلة لإعادة الاستخدام. (Flutter Docs)
تنظيم المشروع بشكل صحيح
عندما يكبر التطبيق، لا تضع كل شيء في ملف واحد. من الأفضل أن تقسّم المشروع إلى أجزاء واضحة. مثلًا:
lib/models/note.dart
lib/screens/home_screen.dart
lib/screens/note_editor_screen.dart
lib/widgets/note_card.dart
lib/main.dart
هذا التنظيم يجعل الكود أسهل في القراءة، وأسهل في التعديل، وأسهل في التوسع. كما أنه يساعدك عندما تبدأ بإضافة تخزين محلي أو ربط التطبيق بواجهة API لاحقًا.
نسخة محسنة مع شاشة مستقلة لإضافة أو تعديل الملاحظة
عند الانتقال من نسخة تعليمية إلى نسخة أكثر نضجًا، من الجيد أن تفصل شاشة الإدخال عن الشاشة الرئيسية. هذا يمنحك تحكمًا أفضل ويجعل التنقل داخل التطبيق أنظف.
class NoteEditorScreen extends StatefulWidget {
final Note? note;
const NoteEditorScreen({super.key, this.note});
@override
State<NoteEditorScreen> createState() => _NoteEditorScreenState();
}
class _NoteEditorScreenState extends State<NoteEditorScreen> {
late final TextEditingController titleController;
late final TextEditingController contentController;
@override
void initState() {
super.initState();
titleController = TextEditingController(text: widget.note?.title ?? '');
contentController = TextEditingController(text: widget.note?.content ?? '');
}
@override
void dispose() {
titleController.dispose();
contentController.dispose();
super.dispose();
}
void _saveNote() {
final title = titleController.text.trim();
final content = contentController.text.trim();
if (title.isEmpty || content.isEmpty) return;
Navigator.pop(context, {
'title': title,
'content': content,
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.note == null ? 'إضافة ملاحظة' : 'تعديل ملاحظة'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(
labelText: 'العنوان',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: contentController,
maxLines: 6,
decoration: const InputDecoration(
labelText: 'المحتوى',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saveNote,
child: const Text('حفظ'),
),
),
],
),
),
);
}
}
هذا الأسلوب أفضل بكثير عندما يتوسع المشروع، لأنه يخفف ضغط المنطق عن الشاشة الرئيسية.
ماذا عن حفظ البيانات؟
النسخة التي بنيناها حتى الآن تحفظ البيانات داخل الذاكرة فقط، وهذا يعني أن الملاحظات ستختفي عند إغلاق التطبيق. في تطبيق حقيقي، ستحتاج إلى طبقة تخزين محلية أو مزامنة سحابية. قد تختار قاعدة محلية بسيطة إن كان المطلوب العمل دون إنترنت، أو ترفع البيانات إلى خدمة سحابية إذا أردت مزامنة بين الأجهزة. المهم أن تفهم أولًا منطق التطبيق ثم تضيف طبقة التخزين المناسبة لاحقًا.
الجميل هنا أن واجهة التطبيق التي أنشأناها ستظل صالحة تقريبًا حتى بعد إضافة التخزين؛ كل ما ستفعله هو استبدال القائمة الداخلية بطبقة بيانات حقيقية.
أخطاء شائعة يجب الانتباه لها
من أكثر الأخطاء شيوعًا نسيان استدعاء dispose على TextEditingController، وهذا قد يسبب هدرًا في الموارد. Flutter يوصي صراحة بالتخلص من controller عندما تنتهي منه.
خطأ آخر هو وضع كل الكود داخل build() بشكل مباشر. الأفضل أن يكون المنطق منفصلًا في دوال مساعدة حتى لا تصبح الواجهة مزدحمة.
ومن الأخطاء أيضًا تجاهل التحقق من المدخلات، أو السماح بحفظ ملاحظة فارغة، أو استخدام ListView عادي لقائمة كبيرة جدًا بدل ListView.builder. Flutter يوضح أن ListView.builder أكثر ملاءمة للقوائم الطويلة.
خلاصة عملية
إنشاء تطبيق ملاحظات باستخدام Flutter ليس مجرد تمرين بسيط، بل هو مشروع صغير يجمع تقريبًا كل الأساسيات التي تحتاجها لبناء تطبيقات أكبر. أنت هنا تتعامل مع إدخال النصوص، عرض القوائم، إدارة الحالة، التعديل، الحذف، البحث، والتحقق من البيانات. وFlutter يوفر لك أدوات واضحة ومباشرة لكل ذلك، مثل StatefulWidget لتخزين الحالة، وTextEditingController للتحكم في النص، وTextField وTextFormField لإدخال البيانات، وListView.builder لعرض القوائم المتنامية.
إذا بدأت بالنسخة البسيطة ثم طورتها خطوة خطوة، فستحصل على تطبيق نظيف ومفيد، والأهم أنك ستفهم كيف يفكر Flutter من الداخل. وهذا الفهم هو الذي يجعل أي مشروع لاحق أسهل وأسرع. فبدل أن تكون مجرد ناسخ للكود، ستصبح قادرًا على بناء التطبيق بنفسك، وتعديل هيكله، وتوسيع مزاياه بثقة.