مشروع Flutter كامل: تطبيق ملاحظات يحفظ بياناته
تعلمت حتى الآن عشرات الأدوات، كل واحدة في درسها. حان وقت جمعها في تطبيق واحد حقيقي: تطبيق ملاحظات تضيف فيه ملاحظة، وتعدّلها، وتحذفها بالسحب، وتجدها كلها محفوظة عندما تفتح التطبيق غداً. المشروع ملفان فقط، لكنه يحتوي على هيكل أغلب التطبيقات التي ستبنيها لاحقاً.
ماذا سنبني؟
Section titled “ماذا سنبني؟”- شاشة رئيسية تعرض الملاحظات في بطاقات، مع حالة خاصة عندما لا توجد ملاحظات.
- زر عائم يفتح شاشة لإضافة ملاحظة جديدة.
- الضغط على ملاحظة يفتح نفس الشاشة لتعديلها.
- سحب الملاحظة جانباً يحذفها، مع زر «تراجع».
- كل تغيير يُحفظ على الجهاز فوراً.
والأدوات التي سنستخدمها كلها مرّت عليك: ListView.builder و Card و Dismissible و Form و Navigator و jsonEncode و shared_preferences.
التخطيط قبل الكود
Section titled “التخطيط قبل الكود”قبل أن تكتب أي سطر، أجب عن ثلاثة أسئلة. هذه العادة توفر عليك ساعات:
- ما البيانات؟ ملاحظة لها معرّف فريد
id، وعنوانtitle، ونصbody. - أين تعيش الحالة؟ قائمة الملاحظات في الشاشة الرئيسية. شاشة التعديل لا تملك القائمة، بل تُرجع ملاحظة واحدة كنتيجة.
- كيف تُحفظ؟ نحوّل القائمة إلى نص JSON ونحفظه تحت مفتاح واحد.
الملف الأول: نموذج البيانات
Section titled “الملف الأول: نموذج البيانات”class Note { const Note({required this.id, required this.title, required this.body});
final String id; final String title; final String body;
factory Note.fromJson(Map<String, dynamic> json) { return Note( id: json['id'] as String, title: json['title'] as String, body: json['body'] as String, ); }
Map<String, dynamic> toJson() { return {'id': id, 'title': title, 'body': body}; }}fromJsonتعرفها من درس الإنترنت: تبني كائناً من خريطة.toJsonعكسها: تحوّل الكائن إلى خريطة، ليستطيعjsonEncodeتحويلها إلى نص.- كل الحقول
final. الملاحظة لا تُعدَّل، بل تُستبدل بملاحظة جديدة لها نفس المعرّف. هذا يجعل تتبع التغييرات أسهل ويمنع أخطاء كثيرة.
الملف الثاني: الشاشتان
Section titled “الملف الثاني: الشاشتان”import 'dart:convert';
import 'package:flutter/material.dart';import 'package:shared_preferences/shared_preferences.dart';
import 'note.dart';
const notesKey = 'notes';
void main() { runApp(const NotesApp());}
class NotesApp extends StatelessWidget { const NotesApp({super.key});
@override Widget build(BuildContext context) { return MaterialApp( title: 'ملاحظاتي', debugShowCheckedModeBanner: false, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.amber), ), home: const Directionality( textDirection: TextDirection.rtl, child: NotesScreen(), ), ); }}
class NotesScreen extends StatefulWidget { const NotesScreen({super.key});
@override State<NotesScreen> createState() => _NotesScreenState();}
class _NotesScreenState extends State<NotesScreen> { List<Note> _notes = []; bool _loading = true;
@override void initState() { super.initState(); _load(); }
Future<void> _load() async { final prefs = await SharedPreferences.getInstance(); final text = prefs.getString(notesKey); final List<dynamic> list = text == null ? [] : jsonDecode(text); if (!mounted) return; setState(() { _notes = list .map((item) => Note.fromJson(item as Map<String, dynamic>)) .toList(); _loading = false; }); }
Future<void> _save() async { final prefs = await SharedPreferences.getInstance(); final list = _notes.map((note) => note.toJson()).toList(); await prefs.setString(notesKey, jsonEncode(list)); }
Future<void> _openEditor([Note? existing]) async { final result = await Navigator.push<Note>( context, MaterialPageRoute( builder: (context) => Directionality( textDirection: TextDirection.rtl, child: EditNoteScreen(note: existing), ), ), ); if (result == null || !mounted) return;
setState(() { final index = _notes.indexWhere((note) => note.id == result.id); if (index == -1) { _notes.insert(0, result); } else { _notes[index] = result; } }); await _save(); }
void _delete(Note note) { final index = _notes.indexOf(note); setState(() => _notes.removeAt(index)); _save();
ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( content: const Text('حُذفت الملاحظة'), action: SnackBarAction( label: 'تراجع', onPressed: () { setState(() { final position = index <= _notes.length ? index : _notes.length; _notes.insert(position, note); }); _save(); }, ), ), ); }
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('ملاحظاتي (${_notes.length})')), body: _buildBody(), floatingActionButton: FloatingActionButton.extended( onPressed: _openEditor, icon: const Icon(Icons.add), label: const Text('ملاحظة جديدة'), ), ); }
Widget _buildBody() { if (_loading) { return const Center(child: CircularProgressIndicator()); } if (_notes.isEmpty) { return const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.note_alt_outlined, size: 64, color: Colors.grey), SizedBox(height: 8), Text('لا توجد ملاحظات بعد'), ], ), ); } return ListView.builder( padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), itemCount: _notes.length, itemBuilder: (context, index) { final note = _notes[index]; return Dismissible( key: ValueKey(note.id), background: Container( color: Colors.red, alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: 16), child: const Icon(Icons.delete, color: Colors.white), ), onDismissed: (direction) => _delete(note), child: Card( child: ListTile( title: Text( note.title, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( note.body, maxLines: 2, overflow: TextOverflow.ellipsis, ), onTap: () => _openEditor(note), ), ), ); }, ); }}
class EditNoteScreen extends StatefulWidget { const EditNoteScreen({super.key, this.note});
final Note? note;
@override State<EditNoteScreen> createState() => _EditNoteScreenState();}
class _EditNoteScreenState extends State<EditNoteScreen> { final _formKey = GlobalKey<FormState>(); late final TextEditingController _titleController; late final TextEditingController _bodyController;
@override void initState() { super.initState(); _titleController = TextEditingController(text: widget.note?.title ?? ''); _bodyController = TextEditingController(text: widget.note?.body ?? ''); }
@override void dispose() { _titleController.dispose(); _bodyController.dispose(); super.dispose(); }
void _submit() { if (!_formKey.currentState!.validate()) return;
final note = Note( id: widget.note?.id ?? DateTime.now().microsecondsSinceEpoch.toString(), title: _titleController.text.trim(), body: _bodyController.text.trim(), ); Navigator.pop(context, note); }
@override Widget build(BuildContext context) { final isEditing = widget.note != null;
return Scaffold( appBar: AppBar( title: Text(isEditing ? 'تعديل الملاحظة' : 'ملاحظة جديدة'), ), body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Form( key: _formKey, child: Column( children: [ TextFormField( controller: _titleController, textInputAction: TextInputAction.next, decoration: const InputDecoration( labelText: 'العنوان', border: OutlineInputBorder(), ), validator: (value) { if (value == null || value.trim().isEmpty) { return 'العنوان مطلوب'; } return null; }, ), const SizedBox(height: 16), TextFormField( controller: _bodyController, maxLines: 6, decoration: const InputDecoration( labelText: 'نص الملاحظة', alignLabelWithHint: true, border: OutlineInputBorder(), ), ), const SizedBox(height: 24), SizedBox( width: double.infinity, child: FilledButton( onPressed: _submit, child: const Text('حفظ'), ), ), ], ), ), ), ); }}شرح أهم الأفكار
Section titled “شرح أهم الأفكار”التحميل والحفظ. _load تقرأ النص المحفوظ، وتحوّله بـ jsonDecode إلى قائمة خرائط، ثم إلى قائمة Note. و _save تعكس الطريق. وفي أول تشغيل يكون النص null فنبدأ بقائمة فارغة.
شاشة واحدة للإضافة والتعديل. EditNoteScreen تستقبل note اختيارية. إذا كانت null فنحن نضيف، وإلا فنحن نعدّل ونملأ الحقول بقيمها. بهذا لم نكتب النموذج مرتين.
الشاشة تُرجع نتيجة. شاشة التعديل لا تعرف شيئاً عن القائمة ولا عن الحفظ. تبني ملاحظة وترجعها بـ Navigator.pop(context, note). والشاشة الرئيسية تنتظرها بـ await، ثم تقرر:
- إذا وُجد في القائمة معرّف مطابق: استبدال (تعديل).
- إذا لم يوجد، فـ
indexWhereتُرجع-1: إضافة في أول القائمة.
المعرّف عند التعديل. السطر widget.note?.id ?? DateTime.now()... يحتفظ بالمعرّف القديم عند التعديل، ويولّد معرّفاً جديداً عند الإضافة.
الحالات الثلاث. _buildBody تعرض دائرة تحميل، ثم إما رسالة «لا توجد ملاحظات بعد» أو القائمة.
مسافة أسفل القائمة. EdgeInsets.fromLTRB(8, 8, 8, 88) تترك 88 بكسلاً في الأسفل، حتى لا يغطي الزر العائم آخر ملاحظة.
جرّب بنفسك
Section titled “جرّب بنفسك”- أنشئ مشروعاً جديداً، وأضف حزمة
shared_preferences، واكتب الملفين وشغّل التطبيق. - أضف ثلاث ملاحظات، وعدّل واحدة، واحذف واحدة ثم تراجع. أغلق التطبيق وافتحه وتأكد أن كل شيء محفوظ.
- أضف إلى
Noteحقلاً للون، واعرضه شريطاً ملوناً على حافة البطاقة. - أضف حقل بحث في أعلى القائمة يصفّي الملاحظات بحسب العنوان.
- تحدٍّ: انقل القائمة ومنطق الحفظ إلى
ChangeNotifier، ووفّره بـProvider، كما تعلمت في الدرس السابق.
أخطاء شائعة
Section titled “أخطاء شائعة”- نسيان استدعاء الحفظ بعد أحد التغييرات: التعديل يظهر الآن ويختفي بعد إعادة التشغيل. كل دالة تغيّر القائمة يجب أن تنتهي بـ
_save. - استخدام index مفتاحاً لـ Dismissible: استخدم
note.id، فهو لا يتغير عند الحذف. - نسيان toJson في أحد الحقول الجديدة: الحقل يعمل حتى إغلاق التطبيق ثم يضيع. عند إضافة حقل، عدّل المُنشئ و
fromJsonوtoJsonمعاً.
اختبر نفسك
Section titled “اختبر نفسك”سؤال
كيف تعرف الشاشة الرئيسية أن الملاحظة الراجعة تعديل وليست إضافة؟
indexWhere تبحث عن ملاحظة بنفس id. إذا وُجدت نستبدلها، وإذا رجعت -1 نضيف الملاحظة كعنصر جديد.
سؤال
لماذا يحتاج كلاس Note إلى دالة toJson؟
shared_preferences تحفظ نصوصاً، و jsonEncode تحتاج خرائط وقوائم. toJson تحوّل الكائن إلى خريطة.
الخلاصة
Section titled “الخلاصة”- خطّط قبل الكود: ما البيانات، وأين تعيش الحالة، وكيف تُحفظ.
- كلاس البيانات بحقول
finalمعfromJsonوtoJsonومعرّف فريد. - شاشة واحدة للإضافة والتعديل تُرجع نتيجتها بـ
Navigator.pop. - كل تغيير في القائمة يتبعه حفظ، والتحميل في
initState. - لا تنسَ الحالات: تحميل، وقائمة فارغة، وبيانات.