ساخت اپلیکیشن یادداشت با فلاتر و دارت — از صفر تا صد

۵۲۹ بازدید
آخرین به‌روزرسانی: ۱۵ مهر ۱۴۰۲
زمان مطالعه: ۸ دقیقه
ساخت اپلیکیشن یادداشت با فلاتر و دارت — از صفر تا صد

فلاتر یک فریمورک توسعه موبایل چندپلتفرمی اوپن سورس است که از سوی گوگل عرضه شده است. اپلیکیشن‌ها‌ی فلاتر با دارت نوشته می‌شوند. فلاتر به صورت پیش‌فرض مجهز به کامپوننت‌های «متریال دیزاین» (Material Design) است و همین امر موجب شده است تا ساخت اپلیکیشن با ظاهر و حس خوب با استفاده از فلاتر بسیار آسان باشد. در فلاتر هر چیزی یک ویجت از نوع باحالت یا بی‌حالت محسوب می‌شود. در این راهنما به عنوان یک پروژه برای شروع یادگیری فلاتر، ‌اقدام به ساخت اپلیکیشن یادداشت با فلاتر و دارت خواهیم کرد.

اگر هنوز فلاتر را روی سیستم خود نصب نکرده‌اید، آن را به همراه یک IDE پشتیبانی‌شده نصب کنید. راهنمایی‌های لازم در این صفحه (+) انجام یافته است.

ابتدا پروژه را تنظیم می‌کنیم. مراحل کار به صورت زیر است:

  1. یک پروژه فلاتر در اندروید استودیو ایجاد کنید یا دستور flutter create notes را در ترمینال یا CMD وارد نمایید.
  2. در فایل main.dart کلاس homepage را حذف کرده و یک فایل جدید با کلاس homepage خودتان ایجاد کنید که Stateful Widget را بسط دهد. این کلاس شامل چارچوب کلی اپلیکیشن ما خواهد بود.
  3. کلاس ویجت باحالت دیگری ایجاد کنید. این کلاس شامل بخش Body است که یک نمای Staggered را برای Home در خود جای داده است. نام آن را StaggeredGridPage می‌گذاریم.

در این اپلیکیشن تلاش می‌کنیم که خلاقیت به خرج بدهیم و یادداشت‌ها را به روش Staggered جالبی نمایش دهیم. از این پکیج دارت برای (+) ‌ایجاد نمای شبکه‌ای Staggered استفاده می‌کنیم. از SQLite نیز برای ذخیره داده‌های یادداشت‌ها روی دستگاه استفاده می‌کنیم.

در ادامه قطعه کدی را از pubspec.yaml می‌بینید که وابستگی‌های فهرست شده را الزام کرده است. آن‌ها را اضافه کرده، فایل را ذخیره کنید و از دستور فلاتر flutter packages get برای نصب وابستگی‌های اضافه شده جدید استفاده کنید.

1dependencies:
2  flutter:
3    sdk: flutter
4
5  cupertino_icons: ^0.1.2
6  flutter_staggered_grid_view: ^0.2.7
7  auto_size_text: ^1.1.2
8  sqflite:
9  path:
10  intl: ^0.15.7
11  share: ^0.6.1

یک کلاس برای یادداشت‌ها ایجاد کنید. ما به تابع toMap برای کوئری‌های پایگاه داده نیاز داریم.

  • فایل note.dart
1class Note {
2  int id;
3  String title;
4  String content;
5  DateTime date_created;
6  DateTime date_last_edited;
7  Color note_color;
8  int is_archived = 0;
9
10  Note(this.id, this.title, this.content, this.date_created, this.date_last_edited,this.note_color);
11
12  Map<String, dynamic> toMap(bool forUpdate) {
13    var data = {
14//      'id': id,  since id is auto incremented in the database we don't need to send it to the insert query.
15      'title': utf8.encode(title),
16      'content': utf8.encode( content ),
17      'date_created': epochFromDate( date_created ),
18      'date_last_edited': epochFromDate( date_last_edited ),
19      'note_color': note_color.value,
20      'is_archived': is_archived  //  for later use for integrating archiving
21    };
22    if(forUpdate){  data["id"] = this.id;  }
23    return data;
24  }
25
26// Converting the date time object into int representing seconds passed after midnight 1st Jan, 1970 UTC
27int epochFromDate(DateTime dt) {  return dt.millisecondsSinceEpoch ~/ 1000; }
28
29void archiveThisNote(){ is_archived = 1; }
30}

کد کوئری‌های پایگاه داده SQLite برای کلاس note فوق و جدول مربوطه به صورت زیر است:

  • فایل SqliteHandler.dart
1import 'package:sqflite/sqflite.dart';
2import 'package:path/path.dart';
3import 'package:sqflite/sqlite_api.dart';
4import 'dart:async';
5import 'Note.dart';
6
7class NotesDBHandler {
8
9  final databaseName = "notes.db";
10  final tableName = "notes";
11
12
13  final fieldMap = {
14    "id": "INTEGER PRIMARY KEY AUTOINCREMENT",
15    "title": "BLOB",
16    "content": "BLOB",
17    "date_created": "INTEGER",
18    "date_last_edited": "INTEGER",
19    "note_color": "INTEGER",
20    "is_archived": "INTEGER"
21  };
22
23
24  static Database _database;
25
26
27  Future<Database> get database async {
28    if (_database != null)
29      return _database;
30
31    _database = await initDB();
32    return _database;
33  }
34
35
36  initDB() async {
37    var path = await getDatabasesPath();
38    var dbPath = join(path, 'notes.db');
39    // ignore: argument_type_not_assignable
40    Database dbConnection = await openDatabase(
41        dbPath, version: 1, onCreate: (Database db, int version) async {
42      print("executing create query from onCreate callback");
43      await db.execute(_buildCreateQuery());
44    });
45
46    await dbConnection.execute(_buildCreateQuery());
47    _buildCreateQuery();
48    return dbConnection;
49  }
50
51
52// build the create query dynamically using the column:field dictionary.
53  String _buildCreateQuery() {
54    String query = "CREATE TABLE IF NOT EXISTS ";
55    query += tableName;
56    query += "(";
57    fieldMap.forEach((column, field){
58      print("$column : $field");
59      query += "$column $field,";
60    });
61
62
63    query = query.substring(0, query.length-1);
64    query += " )";
65
66   return query;
67
68  }
69
70  static Future<String> dbPath() async {
71    String path = await getDatabasesPath();
72    return path;
73  }
74
75  Future<int> insertNote(Note note, bool isNew) async {
76    // Get a reference to the database
77    final Database db = await database;
78    print("insert called");
79
80    // Insert the Notes into the correct table.
81    await db.insert('notes',
82      isNew ? note.toMap(false) : note.toMap(true),
83      conflictAlgorithm: ConflictAlgorithm.replace,
84    );
85
86    if (isNew) {
87      // get latest note which isn't archived, limit by 1
88      var one = await db.query("notes", orderBy: "date_last_edited desc",
89          where: "is_archived = ?",
90          whereArgs: [0],
91          limit: 1);
92      int latestId = one.first["id"] as int;
93      return latestId;
94    }
95    return note.id;
96  }
97
98
99  Future<bool> copyNote(Note note) async {
100    final Database db = await database;
101    try {
102      await db.insert("notes",note.toMap(false), conflictAlgorithm: ConflictAlgorithm.replace);
103    } catch(Error) {
104      print(Error);
105      return false;
106    }
107    return true;
108  }
109
110
111  Future<bool> archiveNote(Note note) async {
112    if (note.id != -1) {
113      final Database db = await database;
114
115      int idToUpdate = note.id;
116
117      db.update("notes", note.toMap(true), where: "id = ?",
118          whereArgs: [idToUpdate]);
119    }
120  }
121
122  Future<bool> deleteNote(Note note) async {
123    if(note.id != -1) {
124      final Database db = await database;
125      try {
126        await db.delete("notes",where: "id = ?",whereArgs: [note.id]);
127        return true;
128      } catch (Error){
129        print("Error deleting ${note.id}: ${Error.toString()}");
130        return false;
131      }
132    }
133  }
134
135
136  Future<List<Map<String,dynamic>>> selectAllNotes() async {
137    final Database db = await database;
138    // query all the notes sorted by last edited
139    var data = await db.query("notes", orderBy: "date_last_edited desc",
140        where: "is_archived = ?",
141        whereArgs: [0]);
142
143    return data;
144
145  }
146
147
148
149}

اینک صفحه اصلی اپلیکیشن متریال باید یک چارچوب (Scaffold) از فایل HomePage.dart داشته باشد که بدنه آن به صورت StaggeredGridView است. در بخش AppBar این چارچوب یک دکمه اکشن قرار می‌دهیم تا کاربر بتواند بین حالت‌های نمایش لیستی و Staggered انتخاب کند. فراموش نکنید که Body را درون SafeArea قرار دهید، چون می‌خواهیم اپلیکیشن روی گوشی‌های مدرن نیز عملکرد مناسبی داشته باشد.

کتابخانه نمای Staggered تعدادی یادداشت برای نما الزام می‌کند که به صورت دینامیک بر مبنای عرض اندازه صفحه نمایش تعیین می‌شود. این وضعیت نیازمند این است که تعداد یادداشت‌هایی که قرار است در کنار هم نمایش یابند، معین شده باشد. در وضعیت افقی گوشی یا روی تبلت، تعداد یادداشت‌ها را به صورت افقی روی 3 عدد و برای وضعیت عمودی روی گوشی روی عدد 2 تنظیم می‌کنیم.

  • فایل StaggeredView.dart
1import 'dart:convert';
2import 'package:flutter/material.dart';
3import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
4import '../Models/Note.dart';
5import '../Models/SqliteHandler.dart';
6import '../Models/Utility.dart';
7import '../Views/StaggeredTiles.dart';
8import 'HomePage.dart';
9
10class StaggeredGridPage extends StatefulWidget {
11  final notesViewType;
12  const StaggeredGridPage({Key key, this.notesViewType}) : super(key: key);
13  @override
14  _StaggeredGridPageState createState() => _StaggeredGridPageState();
15}
16
17class _StaggeredGridPageState extends State<StaggeredGridPage> {
18
19  var  noteDB = NotesDBHandler();
20  List<Map<String, dynamic>> _allNotesInQueryResult = [];
21  viewType notesViewType ;
22
23@override
24  void initState() {
25    super.initState();
26    this.notesViewType = widget.notesViewType;
27  }
28
29@override void setState(fn) {
30    super.setState(fn);
31    this.notesViewType = widget.notesViewType;
32  }
33
34  @override
35  Widget build(BuildContext context) {
36    GlobalKey _stagKey = GlobalKey();
37    if(CentralStation.updateNeeded) {  retrieveAllNotesFromDatabase();  }
38    return Container(child: Padding(padding:  _paddingForView(context) , child:
39      new StaggeredGridView.count(key: _stagKey,
40        crossAxisSpacing: 6, mainAxisSpacing: 6,
41        crossAxisCount: _colForStaggeredView(context),
42        children: List.generate(_allNotesInQueryResult.length, (i){ return _tileGenerator(i); }),
43      staggeredTiles: _tilesForView() ,
44          ),
45        )
46      );
47  }
48
49  int _colForStaggeredView(BuildContext context) {
50      if (widget.notesViewType == viewType.List) { return 1; }
51      // for width larger than 600, return 3 irrelevant of the orientation to accommodate more notes horizontally
52      return MediaQuery.of(context).size.width > 600 ? 3 : 2 ;
53  }
54
55 List<StaggeredTile> _tilesForView() { // Generate staggered tiles for the view based on the current preference.
56  return List.generate(_allNotesInQueryResult.length,(index){ return StaggeredTile.fit( 1 ); }
57  ) ;
58}
59
60EdgeInsets _paddingForView(BuildContext context){
61  double width = MediaQuery.of(context).size.width;
62  double padding ;
63  double top_bottom = 8;
64  if (width > 500) {
65    padding = ( width ) * 0.05 ; // 5% padding of width on both side
66  } else {
67    padding = 8;
68  }
69  return EdgeInsets.only(left: padding, right: padding, top: top_bottom, bottom: top_bottom);
70}
71
72
73 MyStaggeredTile _tileGenerator(int i){
74 return MyStaggeredTile(  Note(
75      _allNotesInQueryResult[i]["id"],
76      _allNotesInQueryResult[i]["title"] == null ? "" : utf8.decode(_allNotesInQueryResult[i]["title"]),
77      _allNotesInQueryResult[i]["content"] == null ? "" : utf8.decode(_allNotesInQueryResult[i]["content"]),
78     DateTime.fromMillisecondsSinceEpoch(_allNotesInQueryResult[i]["date_created"] * 1000),
79     DateTime.fromMillisecondsSinceEpoch(_allNotesInQueryResult[i]["date_last_edited"] * 1000),
80      Color(_allNotesInQueryResult[i]["note_color"] ))
81  );
82  }
83
84  void retrieveAllNotesFromDatabase() {
85  // queries for all the notes from the database ordered by latest edited note. excludes archived notes.
86    var _testData = noteDB.testSelect();
87    _testData.then((value){
88        setState(() {
89          this._allNotesInQueryResult = value;
90          CentralStation.updateNeeded = false;
91        });
92    });
93  }
94}

این نما به کاشی‌هایی (Tiles) برای نمایش یادداشت‌ها نیاز دارد. آن کاشی که ما برای نما طراحی می‌کنیم باید عنوان و محتوای یادداشت را به صورت پیش‌نمایش ارائه کند. برای مدیریت طول مختلف متن یادداشت از یک کتابخانه (+) ‌جهت ایجاد نمای متنی با بسط خودکار استفاده می‌کنیم. کافی است محدودیت خط را تعریف کنیم تا ویجت به صورت خودکار بسط یابد و محتوا را تا جایی که به این محدودیت می‌رسد، نمایش دهد.

همانند segue در iOS و Intent در اندروید، برای ناوبری بین صفحه‌ها در فلاتر از Navigator استفاده می‌کنیم.

  • فایل rawStaggeredTile.dart
1import 'package:flutter/material.dart';
2import 'package:auto_size_text/auto_size_text.dart';
3import '../ViewControllers/NotePage.dart';
4import '../Models/Note.dart';
5import '../Models/Utility.dart';
6
7class MyStaggeredTile extends StatefulWidget {
8  final Note note;
9  MyStaggeredTile(this.note);
10  @override
11  _MyStaggeredTileState createState() => _MyStaggeredTileState();
12}
13
14class _MyStaggeredTileState extends State<MyStaggeredTile> {
15
16  String _content ;
17  double _fontSize ;
18  Color tileColor ;
19  String title;
20
21  @override
22  Widget build(BuildContext context) {
23
24    _content = widget.note.content;
25    _fontSize = _determineFontSizeForContent();
26    tileColor = widget.note.note_color;
27    title = widget.note.title;
28
29    return GestureDetector(
30      onTap: ()=> _noteTapped(context),
31      child: Container(
32      decoration: BoxDecoration(
33        border: tileColor == Colors.white ?   Border.all(color: CentralStation.borderColor) : null,
34          color: tileColor,
35          borderRadius: BorderRadius.all(Radius.circular(8))),
36      padding: EdgeInsets.all(8),
37      child:  constructChild(),) ,
38    );
39  }
40
41  void _noteTapped(BuildContext ctx) {
42    CentralStation.updateNeeded = false;
43    Navigator.push(ctx, MaterialPageRoute(builder: (ctx) => NotePage(widget.note)));
44  }
45
46  Widget constructChild() {
47    List<Widget> contentsOfTiles = [];
48
49    if(widget.note.title.length != 0) {
50      contentsOfTiles.add(
51        AutoSizeText(title,
52          style: TextStyle(fontSize: _fontSize,fontWeight: FontWeight.bold),
53          maxLines: widget.note.title.length == 0 ? 1 : 3,
54          textScaleFactor: 1.5,
55        ),
56      );
57      contentsOfTiles.add(Divider(color: Colors.transparent,height: 6,),);
58    }
59
60    contentsOfTiles.add(
61        AutoSizeText(
62          _content,
63          style: TextStyle(fontSize: _fontSize),
64          maxLines: 10,
65          textScaleFactor: 1.5,)
66    );
67    return Column(
68        crossAxisAlignment: CrossAxisAlignment.start,
69        mainAxisAlignment: MainAxisAlignment.start,
70        children:     contentsOfTiles
71    );
72  }
73
74 double _determineFontSizeForContent() {
75    int charCount = _content.length + widget.note.title.length ;
76    double fontSize = 20 ;
77    if (charCount > 110 ) { fontSize = 12; }
78    else if (charCount > 80) {  fontSize = 14;  }
79    else if (charCount > 50) {  fontSize = 16;  }
80    else if (charCount > 20) {  fontSize = 18;  }
81    return fontSize;
82  }
83}

بدین ترتیب کاشی‌ها در نما، ظاهری مانند زیر پیدا می‌کنند:

اکنون باید یک نما برای ویرایش/ایجاد یادداشت داشته باشیم. در این نما اکشن‌های مختلفی برای Undo، آرشیو کردن و موارد دیگر روی یادداشت‌ها در AppBar تعریف می‌کنیم. اکشن‌های دیگری نیز در بخش تحتانی صفحه به صورت گزینه‌هایی برای اشتراک، کپی گرفتن، ‌حذف دائمی و یک انتخابگر رنگ افقی عرضه می‌شود که امکان تغییر رنگ پس‌زمینه آن یادداشت خاص را فراهم می‌سازد.

بدین ترتیب ویجت‌های NotePage, BottomSheet و ColorSlider را در کلاس‌ها و فایل‌های مختلف قرار می‌دهیم تا کد تمیزتر و منسجم‌تر بماند. در زمان انتخاب رنگ از سوی کاربر در ColorSlider، برای تغییر دادن رنگ در همه این ویجت‌ها باید «حالت» (State) ‌را به‌روزرسانی کنیم. امکان اتصال این سه ویجت از طریق تابع‌های Callback برای پاسخ دادن به تغییرات و به‌روزرسانی خودشان وجود دارد.

  • فایل rawColorSlider.dart
1import 'package:flutter/material.dart';
2
3class ColorSlider extends StatefulWidget {
4  final void Function(Color)  callBackColorTapped ;
5  final Color noteColor ;
6  ColorSlider({@required this.callBackColorTapped, @required this.noteColor});
7  @override
8  _ColorSliderState createState() => _ColorSliderState();
9}
10
11class _ColorSliderState extends State<ColorSlider> {
12
13  final colors = [
14    Color(0xffffffff), // classic white
15    Color(0xfff28b81), // light pink
16    Color(0xfff7bd02), // yellow
17    Color(0xfffbf476), // light yellow
18    Color(0xffcdff90), // light green
19    Color(0xffa7feeb), // turquoise
20    Color(0xffcbf0f8), // light cyan
21    Color(0xffafcbfa), // light blue
22    Color(0xffd7aefc), // plum
23    Color(0xfffbcfe9), // misty rose
24    Color(0xffe6c9a9), // light brown
25    Color(0xffe9eaee)  // light gray
26  ];
27
28   final Color borderColor = Color(0xffd3d3d3);
29   final Color foregroundColor = Color(0xff595959);
30
31  final _check = Icon(Icons.check);
32  Color noteColor;
33  
34  int indexOfCurrentColor;
35  @override void initState() {
36    super.initState();
37    this.noteColor = widget.noteColor;
38    indexOfCurrentColor = colors.indexOf(noteColor);
39  }
40
41  @override
42  Widget build(BuildContext context) {
43    return ListView(
44      scrollDirection: Axis.horizontal,
45      children:
46      List.generate(colors.length, (index)
47      {
48        return
49          GestureDetector(
50              onTap: ()=> _colorChangeTapped(index),
51              child: Padding(
52                  padding: EdgeInsets.only(left: 6, right: 6),
53                  child:Container(
54                  child: new CircleAvatar(
55                    child: _checkOrNot(index),
56                    foregroundColor: foregroundColor,
57                    backgroundColor: colors[index],
58                  ),
59                  width: 38.0,
60                  height: 38.0,
61                  padding: const EdgeInsets.all(1.0), // border width
62                  decoration: new BoxDecoration(
63                    color: borderColor, // border color
64                    shape: BoxShape.circle,
65                  )
66              ) )
67          );
68      })
69      ,);
70  }
71
72  void _colorChangeTapped(int indexOfColor) {
73    setState(() {
74      noteColor = colors[indexOfColor];
75      indexOfCurrentColor = indexOfColor;
76      widget.callBackColorTapped(colors[indexOfColor]);
77    });
78  }
79
80  Widget _checkOrNot(int index){
81    if (indexOfCurrentColor == index) {
82      return _check;
83    }
84    return null;
85  }
86
87}

در نهایت برخی قابلیت‌های مفید دیگر از قبیل Undo کردن تغییرها، آرشیو کردن، اشتراک، کپی کردن یادداشت و حذف دائمی آن را نیز به اپلیکیشن اضافه کردیم. کد کامل این اپلیکیشن فلاتر را می‌توانید از این ریپازیتوری گیت‌هاب (+) دانلود کنید و تغییرهای مورد نظر خود را روی آن اعمال کنید.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۶ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
aubergine-solutions
نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *