ساخت اپلیکیشن یادداشت با فلاتر و دارت — از صفر تا صد
فلاتر یک فریمورک توسعه موبایل چندپلتفرمی اوپن سورس است که از سوی گوگل عرضه شده است. اپلیکیشنهای فلاتر با دارت نوشته میشوند. فلاتر به صورت پیشفرض مجهز به کامپوننتهای «متریال دیزاین» (Material Design) است و همین امر موجب شده است تا ساخت اپلیکیشن با ظاهر و حس خوب با استفاده از فلاتر بسیار آسان باشد. در فلاتر هر چیزی یک ویجت از نوع باحالت یا بیحالت محسوب میشود. در این راهنما به عنوان یک پروژه برای شروع یادگیری فلاتر، اقدام به ساخت اپلیکیشن یادداشت با فلاتر و دارت خواهیم کرد.
اگر هنوز فلاتر را روی سیستم خود نصب نکردهاید، آن را به همراه یک IDE پشتیبانیشده نصب کنید. راهنماییهای لازم در این صفحه (+) انجام یافته است.
ابتدا پروژه را تنظیم میکنیم. مراحل کار به صورت زیر است:
- یک پروژه فلاتر در اندروید استودیو ایجاد کنید یا دستور flutter create notes را در ترمینال یا CMD وارد نمایید.
- در فایل main.dart کلاس homepage را حذف کرده و یک فایل جدید با کلاس homepage خودتان ایجاد کنید که Stateful Widget را بسط دهد. این کلاس شامل چارچوب کلی اپلیکیشن ما خواهد بود.
- کلاس ویجت باحالت دیگری ایجاد کنید. این کلاس شامل بخش 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 کردن تغییرها، آرشیو کردن، اشتراک، کپی کردن یادداشت و حذف دائمی آن را نیز به اپلیکیشن اضافه کردیم. کد کامل این اپلیکیشن فلاتر را میتوانید از این ریپازیتوری گیتهاب (+) دانلود کنید و تغییرهای مورد نظر خود را روی آن اعمال کنید.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامه نویسی اندروید
- مجموعه آموزشهای برنامهنویسی
- آموزش فریم ورک Google Flutter برای طراحی اپلیکیشن های موبایل
- مفاهیم مقدماتی فلاتر (Flutter) — به زبان ساده
- گوگل فلاتر (Flutter) از صفر تا صد — ساخت اپلیکیشن به کمک ویجت
==