📖 Flutter & Dart: Kapsamlı Geliştirme Müfredatı
Bu
rehber, paylaştığınız ders içeriğinin mantıksal akışına göre tasarlanmış, özet
içermeyen, bol kod örnekli ve uygulamalı eksiksiz bir öğrenim
yol haritasıdır. Belgelerinizde yer alan süre, ders sayısı, "Önizleme" ve anlamsız ifadeler tamamen temizlenerek
doğrudan teknik detaylara ve pratik uygulamalara odaklanılmıştır.
✅ "Öğrenecekleriniz"
Bölümünün Açıklaması
Bu
eğitim içeriği, modern ve çoklu platform destekli mobil uygulama geliştirme
sürecini sıfırdan ileri seviyeye taşımayı hedefler. Bölümün temel vaatleri
şunlardır:
🛠️ Modül 1:
Giriş, Kurulum ve İlk Adımlar
Flutter platformunun çalışma prensiplerini anlamak, üzerine inşa
edeceğimiz tüm projelerin temelini oluşturur.
Temel
Kavramlar ve Çalışma Mantığı
Flutter, bizzat Google tarafından geliştirilen bir UI (Arayüz) araç
takımıdır. Rakip altyapıların aksine, web görünümü (WebView)
veya platform köprüleri kullanmaz. Dart dilinde yazılan kodlar, doğrudan
cihazın işlemcisinin anlayacağı makine koduna (C/C++ mimarisine)
dönüştürülür. Bu sayede saniyede 60 ila 120 kare (FPS) hızında çalışan pürüzsüz
animasyonlar elde edilir.
Geliştirme
Ortamının Hazırlanması
🎲 Modül 2: Flutter & Dart Temelleri I [ZAR ATMA UYGULAMASI]
Bu
aşamada Dart dilinin çekirdek yapı taşlarını öğrenirken, ilk görsel projemizi
hayata geçiriyoruz.
Teknik
Kavramların Detayları
💻 Uygulama: Zar Atma
Uygulaması (Roll Dice App)
Kullanıcının
ekrandaki butona basmasıyla rastgele (Random) bir
sayı üreten ve bu sayıya uygun zar görselini ekrana getiren etkileşimli bir
uygulama projesidir.
import 'package:flutter/material.dart';
import 'dart:math';
// Rastgele sayı
üreteci için global nesne
final randomizer = Random();
void main() {
runApp(
const MaterialApp(
home:
Scaffold(
body: GradientContainer(
Color.fromARGB(255,
33, 5, 109),
Color.fromARGB(255,
68, 21, 149),
),
),
),
);
}
// Arka plan gradyanı
için özel bir StatelessWidget
class GradientContainer extends StatelessWidget
{
const GradientContainer(this.color1,
this.color2, {super.key});
final Color color1;
final Color color2;
@override
Widget build(context) {
return Container(
decoration:
BoxDecoration(
gradient:
LinearGradient(
colors:
[color1, color2],
begin:
Alignment.topLeft,
end:
Alignment.bottomRight,
),
),
child:
const Center(
child:
DiceRoller(),
),
);
}
}
// Değişebilir durumlu
(Stateful) Zar Yuvarlama Widget'ı
class DiceRoller extends StatefulWidget
{
const DiceRoller({super.key});
@override
State<DiceRoller> createState() {
return _DiceRollerState();
}
}
class _DiceRollerState extends State<DiceRoller> {
var
currentDiceRoll = 1;
void rollDice() {
setState(() {
//
1 ile 6 arasında rastgele sayı üretir
currentDiceRoll
= randomizer.nextInt(6)
+ 1;
});
}
@override
Widget build(context) {
return Column(
mainAxisSize:
MainAxisSize.min,
children:
[
Image.asset(
'assets/images/dice-$currentDiceRoll.png',
width:
200,
),
const SizedBox(height: 20),
TextButton(
onPressed:
rollDice,
style:
TextButton.styleFrom(
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize:
28),
),
child:
const Text('Zarı Et'),
)
],
);
}
}
📝 Modül 3: Flutter & Dart Temelleri II [KONTROL LİSTELİ BİLGİ
YARIŞMASI]
Uygulama
içi veri modellerinin kurulmasını, listelerin dönüştürülmesini ve ekranlar
arası mantıksal geçişleri kapsayan ileri temel düzeydir.
Teknik
Kavramların Detayları
💻 Uygulama: Bilgi Yarışması
Uygulaması (Quiz App)
Soruların
sırayla geldiği, kullanıcının şıkları işaretlediği, arka planda cevapların
toplandığı ve en son sonuç ekranında (Results Screen) doğru/yanlış analizinin Google Fonts
fontlarıyla listelendiği gelişmiş bir mimaridir.
// Veri Modeli
Tasarımı
class QuizQuestion {
const QuizQuestion(this.text, this.answers);
final String text;
final List<String>
answers;
//
Orijinal listeyi bozmadan şıkları karıştırarak döndüren metot
List<String> getShuffledAnswers() {
final shuffledList = List.of(answers);
shuffledList.shuffle();
return shuffledList;
}
}
// Örnek Soru Verisi
const questions = [
QuizQuestion(
'Flutter ana arayüz bileşenlerine ne ad verilir?',
['Widget', 'Component', 'Element', 'View'],
),
QuizQuestion(
'Dart
dilinde değiştirilemeyen sabit değişken hangisidir?',
['final', 'var', 'dynamic', 'int'],
),
];
// Yarışma Ekranı
Tasarımı (Soruların Gösterileceği Alan)
import 'package:flutter/material.dart';
class QuestionsScreen extends StatefulWidget
{
const QuestionsScreen({super.key, taxedOnSelectAnswer});
final void Function(String answer)
onSelectAnswer;
@override
State<QuestionsScreen> createState() => _QuestionsScreenState();
}
class _QuestionsScreenState extends State<QuestionsScreen> {
var
currentQuestionIndex = 0;
void answerQuestion(String selectedAnswer) {
widget.onSelectAnswer(selectedAnswer);
setState(() {
currentQuestionIndex++;
// Sonraki soruya geçer
});
}
@override
Widget build(BuildContext context) {
final currentQuestion = questions[currentQuestionIndex];
return SizedBox(
width:
double.infinity,
child:
Container(
margin:
const EdgeInsets.all(40),
child:
Column(
mainAxisAlignment:
MainAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.stretch,
children:
[
Text(
currentQuestion.text,
style: const TextStyle(color:
Colors.white, fontSize: 24),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
// Şıkları dinamik olarak butona dönüştürüp ekrana basıyoruz
...currentQuestion.getShuffledAnswers().map((answer)
{
return ElevatedButton(
onPressed: () => answerQuestion(answer),
child: Text(answer),
);
}),
],
),
),
);
}
}
📊 Modül 4:
Etkileşim, Listeler ve Temalandırma [HARCAMA TAKİP
UYGULAMASI]
Bu
modülde kullanıcı girdilerini doğrulamayı, modern Material
3 standartlarında temalandırma yapmayı ve dinamik
listelerle çalışmayı öğreneceksiniz.
Teknik
Kavramların Detayları
💻 Uygulama: Harcama Takipçisi
(Expense Tracker)
Kullanıcının
harcama adı, miktar, tarih ve kategori (Enum) seçerek
yeni harcamalar eklediği, bunları grafikler halinde gördüğü ve kaydırarak
silebildiği finansal takip uygulamasıdır.
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:uuid/uuid.dart';
final formatter = DateFormat.yMd();
const uuid = Uuid();
enum Category { food, travel, leisure,
work }
// Veri Modeli
class Expense {
Expense({
required this.title,
required this.amount,
required this.date,
required this.category,
}) : id = uuid.v4();
// Benzersiz ID üretimi
final String id;
final String title;
final double amount;
final DateTime date;
final Category category;
String get formattedDate
=> formatter.format(date);
}
// Harcama Kartı
Tasarımı (Custom List Item)
class ExpenseItem extends StatelessWidget
{
const ExpenseItem(this.expense, {super.key});
final Expense expense;
@override
Widget build(BuildContext context) {
return Card(
child:
Padding(
padding:
const EdgeInsets.symmetric(horizontal:
20, vertical: 16),
child:
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children:
[
Text(expense.title,
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 4),
Row(
children: [
Text('\$${expense.amount.toStringAsFixed(2)}'),
const Spacer(), // Aradaki tüm boşluğu kaplar
Row(
children: [
const Icon(Icons.calendar_month),
const SizedBox(width: 8),
Text(expense.formattedDate),
],
),
],
)
],
),
),
);
}
}
📱 Modül 5:
Duyarlı & Adaptif Arayüzler (Responsiveness)
Farklı
ekran boyutlarına, tabletlere ve yatay/dikey ekran döndürmelerine uyum sağlayan
arayüz tasarlama stratejisidir.
Teknik
Kavramların Detayları
💻 Uygulama Yapısı: Adaptif
Ekran Ayrımı
@override
Widget build(BuildContext context)
{
return LayoutBuilder(builder: (ctx, constraints) {
//
Genişlik 600px'den büyükse (Örn: Yatay mod veya
Tablet)
if (constraints.maxWidth
> 600) {
return Row(
children:
[
Expanded(child: TextField(controller: _titleController)),
Expanded(child: TextField(controller: _amountController)),
],
);
} else {
//
Dikey mod standart görünüm
return Column(
children:
[
TextField(controller: _titleController),
TextField(controller: _amountController),
],
);
}
});
}
⚙️ Modül 6: Flutter & Dart Derinlikleri (Internals)
[TODO UYGULAMASI]
Uygulamanın
performans optimizasyonunu elinizde tutabilmeniz için motorun arka planda nasıl
çalıştığını inceleyen teorik ve pratik modüldür.
Üçlü
Ağaç Mimarisi (The Three Trees)
Flutter arka planda performans elde etmek için eşzamanlı çalışan üç
farklı ağaç yapısı yönetir:
Anahtar
(Keys) Kavramı
Ekranda
dinamik bir listede elemanların yerleri değiştiğinde (örneğin bir Todo listesinde bir eleman silindiğinde veya
sıralandığında), Flutter eleman ağacındaki verilerle widget'ları karıştırabilir. Bunun önüne geçmek ve her widget'a benzersiz bir kimlik vermek için ValueKey veya ObjectKey yapıları kullanılır.
🗺️ Modül 7:
Çoklu Ekranlar ve Navigasyon [YEMEK TARİFİ UYGULAMASI]
Uygulama
içinde pencereler oluşturma, verileri güvenli şekilde bir ekrandan diğerine
aktarma mimarisidir.
Teknik
Kavramların Detayları
💻 Uygulama: Yemek Tarifleri
Uygulaması (Meals App)
Kategorilerin
listelendiği, tıklanınca ilgili yemeklerin Stack
yapısıyla listelendiği, detay ekranına tıklandığında favorilere ekleme
fonksiyonunun layerlar (katmanlar) arasından
aktarıldığı kapsamlı bir uygulamadır.
// Navigasyon ile
Detay Ekranına Geçiş Örneği
void _selectMeal(BuildContext context, Meal meal)
{
Navigator.of(context).push(
MaterialPageRoute(
builder:
(ctx) => MealDetailsScreen(
meal: meal,
onToggleFavorite:
onToggleFavorite,
),
),
);
}
🌊 Modül 8:
Global Durum Yönetimi (State Management) [RIVERPOD]
Widget ağacında verileri constructor'lar
(yapıcı fonksiyonlar) aracılığıyla 5-6 katman aşağıya taşımak kod karmaşasına
sebep olur. Bu problemi kökten çözmek için merkezi durum yönetim araçları
kullanılır.
Riverpod Mimarisi
Riverpod, uygulamanın durumunu (State) widget ağacından tamamen bağımsız bir alanda depolar.
İsteyen her widget, aradaki katmanlardan bağımsız
olarak bu veriye doğrudan erişebilir veya veriyi güncelleyebilir.
💻 Uygulama Yapısı: Favori
Yemekler Yönetimi
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 1. Eyalet/Durum
Yönetici Sınıfı Oluşturma
class FavoriteMealsNotifier extends StateNotifier<List<Meal>> {
FavoriteMealsNotifier() : super([]); // Başlangıçta boş liste
bool
toggleMealFavoriteStatus(Meal meal) {
final mealIsFavorite = state.contains(meal);
if (mealIsFavorite)
{
//
Listeden çıkar (Yeni bir bellek adresiyle atama yapılmalı)
state
= state.where((m) => m.id != meal.id).toList();
return false;
} else {
//
Listeye ekle
state
= [...state, meal];
return true;
}
}
}
// 2. Global olarak
erişilebilir Provider tanımı
final favoriteMealsProvider
= StateNotifierProvider<FavoriteMealsNotifier,
List<Meal>>((ref)
{
return FavoriteMealsNotifier();
});
🎬 Modül 9:
Animasyonlar (Animations)
Kullanıcı
deneyimini güçlendirmek amacıyla Flutter'da iki tip
animasyon yöntemi kullanılır.
Animasyon
Türleri
📋 Modül 10:
Formlar ve Kullanıcı Girdileri [ALIŞVERİŞ LİSTESİ]
Verilerin
sunucuya gönderilmeden önce cihaz üzerinde kontrol edilmesi, boş veya hatalı
alanların yakalanması sürecidir.
Form
Bileşenleri
💻 Uygulama: Doğrulamalı Yeni
Ürün Formu
final _formKey = GlobalKey<FormState>(); // Form anahtarı
var _enteredName =
'';
void _saveItem() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); // Onsaved fonksiyonlarını tetikler
//
Veri tabanına göndermeye hazır!
print(_enteredName);
}
}
@override
Widget build(BuildContext context)
{
return Form(
key:
_formKey,
child:
Column(
children:
[
TextFormField(
decoration:
const InputDecoration(label: Text('Ürün Adı')),
validator:
(value) {
if (value == null || value.isEmpty
|| value.trim().length
<= 1) {
return 'Lütfen
geçerli bir ürün adı giriniz.';
}
return null;
},
onSaved:
(value) {
_enteredName
= value!;
},
),
ElevatedButton(onPressed: _saveItem,
child: const Text('Kaydet'))
],
),
);
}
🌐 Modül 11:
HTTP İstekleri ve Backend Entegrasyonu
Uygulamanın
verileri kendi içinde değil, internet üzerindeki canlı bir veri tabanında (Örn: Firebase Realtime
Database) saklaması işlemidir.
Teknik
Kavramların Detayları
💻 Uygulama: Firebase Ürün Ekleme/Çekme İşlemi
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<void> _loadItems() async {
final url = Uri.https('://firebaseio.com', 'shopping-list.json');
try {
final response = await http.get(url);
if (response.statusCode
>= 400) {
//
Hata yönetimi penceresi tetiklenir
return;
}
final Map<String,
dynamic> listData =
json.decode(response.body);
//
Gelen JSON veri modele dönüştürülüp ekrana basılır.
} catch (error) {
//
İnternet kesilmesi gibi ekstrem hatalar yakalanır
}
}
📸 Modül 12:
Cihaz Özelliklerini Kullanma [FAVORİ MEKANLARIM]
Kullanıcının
telefon donanımlarına erişim sağlayarak uygulamayı zenginleştirme aşamasıdır.
Donanım
Entegrasyonları
💬 Modül 13:
Canlı Sohbet ve Bildirimler [CHAT APP & FIREBASE]
Gerçek
zamanlı veri akışları ve kullanıcı etkileşim sistemlerinin kurulduğu en üst
uzmanlık modülüdür.
Teknik
Kavramların Detayları
Harika
bir karar! Her uygulamayı mimari tasarımıyla, dosya yapısıyla ve tüm
sayfalarıyla adım adım kodlayarak ilerlemek, konuyu tamamen sindirmenizi
sağlayacaktır.
Bu
uzun soluklu seriye, müfredatımızın ilk pratik projesi olan ve durum
yönetiminin (StatefulWidget) temelini atan Zar
Atma Uygulaması (Roll Dice App) ile başlıyoruz.
🎲 Proje 1:
Zar Atma Uygulaması (Roll Dice
App)
Bu
projede; tek kod tabanıyla şık bir arka plan gradyanı oluşturmayı, varlıkları
(resimleri) projeye dahil etmeyi ve butona basıldığında rastgele zar değiştiren
stateful (durumlu) mimariyi kuracağız.
📁 Adım 1:
Proje Dosya Yapısı
Öncelikle
Flutter projenizin lib klasörünü ve resim
klasörlerini aşağıdaki hiyerarşide düzenleyin:
guncel_zar_projesi/
├──
assets/
│ └── images/
│ ├── dice-1.png
│ ├── dice-2.png
│ ├── dice-3.png
│ ├── dice-4.png
│ ├── dice-5.png
│ └── dice-6.png
├──
lib/
│ ├── main.dart
│ ├── gradient_container.dart
│ └── dice_roller.dart
└──
pubspec.yaml
⚙️ Adım 2:
Resimlerin Tanımlanması (pubspec.yaml)
Zar
resimlerinin Flutter tarafından tanınması için pubspec.yaml dosyasını açın ve assets bölümünü şu şekilde
güncelleyin (Boşluklara dikkat edin):
flutter:
uses-material-design: true
assets:
- assets/images/
💻 Adım 3:
Adım Adım Kodlama
1.
lib/dice_roller.dart (Zarın Durumunu ve
Değişimini Yöneten Sayfa)
Bu
dosya, kullanıcının butona basma etkileşimini dinler, rastgele sayı üretir ve
ekrandaki resmi günceller.
import 'package:flutter/material.dart';
import 'dart:math';
// Bellek
optimizasyonu için rastgele sayı üretecini sınıf dışında tek bir kez
oluşturuyoruz.
final randomizer = Random();
class DiceRoller extends StatefulWidget
{
const DiceRoller({super.key});
@override
State<DiceRoller> createState() {
return _DiceRollerState();
}
}
class _DiceRollerState extends State<DiceRoller> {
//
Başlangıçta zarın 1 numaralı resimle gelmesini sağlıyoruz.
var
currentDiceRoll = 1;
void rollDice() {
//
setState, Flutter'a bu widget'ın durumunun değiştiğini ve arayüzün yeniden
çizilmesi gerektiğini söyler.
setState(() {
// .nextInt(6) bize 0 ile 5
arasında sayı verir. +1 ekleyerek 1-6 arasına çekiyoruz.
currentDiceRoll
= randomizer.nextInt(6)
+ 1;
});
}
@override
Widget build(context) {
return Column(
//
Column'un ekranda dikey olarak minimum yer
kaplamasını, yani ortalanmasını sağlar.
mainAxisSize:
MainAxisSize.min,
children:
[
Image.asset(
'assets/images/dice-$currentDiceRoll.png',
width:
200,
),
//
Resim ile buton arasına 20 piksellik sabit bir boşluk bırakır.
const SizedBox(height: 20),
TextButton(
onPressed:
rollDice,
style:
TextButton.styleFrom(
foregroundColor: Colors.white,
textStyle: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
child:
const Text('Zarı At!'),
)
],
);
}
}
2.
lib/gradient_container.dart (Arka Plan Tasarım Sayfası)
Uygulamanın
görsel kalitesini artırmak için iki rengi birbirine geçişli (gradyan) hale
getiren temiz bir konteyner tasarımıdır.
import 'package:flutter/material.dart';
import 'package:guncel_zar_projesi/dice_roller.dart'; //
Kendi proje adınıza göre güncelleyin
class GradientContainer extends StatelessWidget
{
//
Yapıcı metot (Constructor) ile dışarıdan dinamik
olarak iki renk kabul ediyoruz.
const GradientContainer(this.color1,
this.color2, {super.key});
final Color color1;
final Color color2;
@override
Widget build(context) {
return Container(
decoration:
BoxDecoration(
gradient:
LinearGradient(
colors:
[color1, color2],
begin:
Alignment.topLeft,
end:
Alignment.bottomRight,
),
),
child:
const Center(
//
İçeriği tam merkeze alıp az önce oluşturduğumuz Zar Atıcı bileşenini
çağırıyoruz.
child:
DiceRoller(),
),
);
}
}
3.
lib/main.dart (Uygulamanın Giriş Noktası)
Uygulamanın
ilk çalıştırıldığı yerdir. Temel malzeme tasarımını (MaterialApp) ve ekran iskeletini (Scaffold) ayağa kaldırır.
import 'package:flutter/material.dart';
import 'package:guncel_zar_projesi/gradient_container.dart';
void main() {
runApp(
const MaterialApp(
debugShowCheckedModeBanner:
false, //
Sağ üstteki "Debug" şeridini kaldırır.
home:
Scaffold(
body: GradientContainer(
Color.fromARGB(255,
33, 5, 109),
Color.fromARGB(255,
68, 21, 149),
),
),
),
);
}
Harika!
Zar Atma Uygulamasını tamamladığımıza göre, şimdi çok daha gelişmiş bir
mimariye sahip olan Proje 2: Bilgi Yarışması Uygulaması (Quiz
App) projesine geçiyoruz.
Bu
projede; ekranlar arası dinamik geçişleri, veri modelleri oluşturmayı,
listeleri manipüle etmeyi (şıkları karıştırmayı) ve kullanıcının verdiği
cevapları hafızada tutarak finalde bir sonuç ekranı sergilemeyi öğreneceğiz.
📝 Proje 2:
Bilgi Yarışması Uygulaması (Quiz App)
📁 Adım 1:
Proje Dosya Yapısı
Uygulamamız
modüler bir yapıda olacağı için kodları mantıksal dosyalara böleceğiz. lib klasörünüzü şu şekilde yapılandırın:
bilgi_yarismasi_app/
├──
lib/
│ ├── models/
│ │
└── quiz_question.dart
│ ├── data/
│ │
└── questions.dart
│ ├── screens/
│ │
├── start_screen.dart
│ │
├── questions_screen.dart
│ │
└── results_screen.dart
│ ├── quiz.dart
│ └── main.dart
└──
pubspec.yaml
⚙️ Adım 2:
Google Fonts Paketinin Kurulması (pubspec.yaml)
Sonuç
ekranında ve sorularda şık yazı tipleri kullanmak için google_fonts paketini projemize ekliyoruz. pubspec.yaml dosyanızın dependencies bölümünün altına şu satırı ekleyin:
dependencies:
flutter:
sdk: flutter
google_fonts: ^6.1.0
Not:
Paket listesini ekledikten sonra terminalde flutter pub
get komutunu çalıştırmayı unutmayın.
💻 Adım 3:
Adım Adım Kodlama
1.
lib/models/quiz_question.dart (Soru Veri Modeli)
Sorularımızın
metnini ve şıklarını nesne yönelimli olarak temsil edecek şablon sınıftır.
class QuizQuestion {
const QuizQuestion(this.text, this.answers);
final String text;
final List<String>
answers; //
İlk eleman her zaman doğru cevap olacak şekilde veri setini kuracağız.
//
Orijinal listeyi bozmadan, şıkları karıştırarak arayüze gönderen metot
List<String> getShuffledAnswers() {
final shuffledList = List.of(answers); // Orijinal listenin kopyasını oluşturur
shuffledList.shuffle();
// Kopyalanan listeyi rastgele karıştırır
return shuffledList;
}
}
2.
lib/data/questions.dart (Yarışma Soruları Veri Seti)
Uygulamada
kullanılacak soruları bu dosyada tanımlıyoruz. Kolay kontrol için her
sorunun ilk şıkkını doğru cevap olarak yazıyoruz. Arayüzde getShuffledAnswers() çağıracağımız için kullanıcı
her zaman karışık görecektir.
import 'package:bilgi_yarismasi_app/models/quiz_question.dart';
const questions = [
QuizQuestion(
'Flutter\'da arayüz elemanlarının render
edilmesini tetikleyen temel durum yönetim metodu hangisidir?',
['setState()', 'initState()', 'build()', 'dispose()'],
),
QuizQuestion(
'Dart
dilinde bir değişkenin değerinin sadece "derleme zamanında" sabit
kalacağını belirten anahtar kelime hangisidir?',
['const', 'final', 'var', 'static'],
),
QuizQuestion(
'Widget ağacındaki elemanların yer değiştirdiğinde durumunu
koruması veya benzersiz şekilde tanımlanması için hangisi kullanılır?',
['Key',
'State', 'BuildContext', 'Theme'],
),
QuizQuestion(
'Ekrandaki
içerik dikey boyutu aştığında alınan taşma (overflow)
hatasını engellemek için hangi widget kullanılır?',
['SingleChildScrollView', 'Column', 'Container', 'Stack'],
),
];
3.
lib/screens/start_screen.dart (Giriş / Karşılama Ekranı)
Uygulama
açıldığında kullanıcının karşılaştığı, yarışmayı başlatan butonu barındıran
ekrandır.
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class StartScreen extends StatelessWidget
{
//
startQuiz fonksiyonunu üst widget'tan
(quiz.dart) parametre olarak
alıyoruz.
const StartScreen(this.startQuiz, {super.key});
final void Function() startQuiz;
@override
Widget build(BuildContext context) {
return Center(
child:
Column(
mainAxisSize:
MainAxisSize.min,
children:
[
// Sayfa ortasında şık bir logo veya ikon görünümü
const Icon(
Icons.quiz_outlined,
color: Colors.white,
size: 150,
),
const SizedBox(height: 40),
Text(
'Flutter Bilgi Yarışması',
style: GoogleFonts.lato(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 30),
OutlinedButton.icon(
onPressed: startQuiz, // Tıklanınca quiz.dart'taki ekran
değiştirme fonksiyonu tetiklenir.
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color:
Colors.white),
),
icon: const Icon(Icons.arrow_right_alt),
label: const Text('Yarışmayı
Başlat'),
),
],
),
);
}
}
4.
lib/screens/questions_screen.dart (Soru ve Cevap Seçim Ekranı)
Soruların
sırayla gösterildiği ve kullanıcının şıklara tıkladığı dinamik ekrandır.
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:bilgi_yarismasi_app/data/questions.dart';
class QuestionsScreen extends StatefulWidget
{
const QuestionsScreen({super.key, required this.onSelectAnswer});
final void Function(String answer)
onSelectAnswer;
@override
State<QuestionsScreen> createState() => _QuestionsScreenState();
}
class _QuestionsScreenState extends State<QuestionsScreen> {
var
currentQuestionIndex = 0;
void answerQuestion(String selectedAnswer) {
//
Seçilen cevabı ana listeye eklemesi için üst widget'a
gönderiyoruz
widget.onSelectAnswer(selectedAnswer);
//
Sıradaki soruya geçiş için indeksi artırıyoruz
setState(() {
currentQuestionIndex++;
});
}
@override
Widget build(BuildContext context) {
final currentQuestion = questions[currentQuestionIndex];
return SizedBox(
width:
double.infinity,
child:
Container(
margin:
const EdgeInsets.all(40),
child:
Column(
mainAxisAlignment:
MainAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.stretch,
children:
[
Text(
currentQuestion.text,
style: GoogleFonts.lato(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
// Spread operatörü (...) kullanarak dönüştürülen buton
listesini Column içine açıyoruz.
...currentQuestion.getShuffledAnswers().map((answer)
{
return Container(
margin: const EdgeInsets.symmetric(vertical:
5),
child: ElevatedButton(
onPressed: () => answerQuestion(answer),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical:
10, horizontal: 40),
backgroundColor: const Color.fromARGB(255,
33, 1, 95),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(40),
),
),
child: Text(answer, textAlign: TextAlign.center),
),
);
}),
],
),
),
);
}
}
5.
lib/screens/results_screen.dart (Sonuç ve Analiz Ekranı)
Yarışma
bittiğinde kullanıcının doğru/yanlış cevaplarını analiz eden ve skoru gösteren
kaydırılabilir ekrandır.
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:bilgi_yarismasi_app/data/questions.dart';
class ResultsScreen extends StatelessWidget
{
const ResultsScreen({
super.key,
required this.chosenAnswers,
required this.onRestart,
});
final List<String>
chosenAnswers;
final void Function() onRestart;
//
Verileri haritalandırıp (Map) eşleştiren yardımcı
metot
List<Map<String, Object>> getSummaryData()
{
final List<Map<String, Object>> summary =
[];
for (var i = 0; i < chosenAnswers.length;
i++) {
summary.add({
'question_index': i,
'question': questions[i].text,
'correct_answer': questions[i].answers[0],
// İlk eleman doğruydu
'user_answer': chosenAnswers[i],
});
}
return summary;
}
@override
Widget build(BuildContext context) {
final summaryData = getSummaryData();
final numTotalQuestions = questions.length;
//
Doğru cevap sayısını hesaplama (Filtreleme yöntemi ile)
final numCorrectQuestions = summaryData.where((data) {
return data['user_answer'] == data['correct_answer'];
}).length;
return SizedBox(
width:
double.infinity,
child:
Container(
margin:
const EdgeInsets.all(40),
child:
Column(
mainAxisAlignment:
MainAxisAlignment.center,
children:
[
Text(
'$numTotalQuestions
sorudan $numCorrectQuestions
tanesine doğru cevap verdiniz!',
style: GoogleFonts.lato(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
// Analiz listesini kaydırılabilir yapmak için kısıtlı bir alana
alıyoruz.
Expanded(
child: SingleChildScrollView(
child: Column(
children: summaryData.map((data)
{
final isCorrect = data['user_answer'] == data['correct_answer'];
return Padding(
padding: const EdgeInsets.symmetric(vertical:
8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Soru Numarası Yuvarlağı
Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isCorrect
? Colors.green : Colors.red,
shape: BoxShape.circle,
),
child: Text(
((data['question_index'] as int) + 1).toString(),
style: const TextStyle(fontWeight:
FontWeight.bold, color: Colors.white),
),
),
const SizedBox(width: 20),
// Soru ve Cevap Detayları
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data['question'] as String,
style: GoogleFonts.lato(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 5),
Text('Sizin Cevabınız: ${data['user_answer']}', style: TextStyle(color: isCorrect ? Colors.green[200] : Colors.red[200])),
Text('Doğru Cevap: ${data['correct_answer']}', style: const TextStyle(color: Colors.blueAccent)),
],
),
),
],
),
);
}).toList(),
),
),
),
const SizedBox(height: 30),
TextButton.icon(
onPressed: onRestart,
style: TextButton.styleFrom(foregroundColor: Colors.white),
icon: const Icon(Icons.refresh),
label: const Text('Yarışmayı
Yeniden Başlat!'),
),
],
),
),
);
}
}
6.
lib/quiz.dart (Ana Ekran Denetleyicisi ve
Durum Yönetimi)
Hangi
ekranın (Start, Questions, Results)
o an aktif olacağını yöneten ana StatefulWidget orkestrasyon dosyasıdır.
import 'package:flutter/material.dart';
import 'package:bilgi_yarismasi_app/data/questions.dart';
import 'package:bilgi_yarismasi_app/screens/start_screen.dart';
import 'package:bilgi_yarismasi_app/screens/questions_screen.dart';
import 'package:bilgi_yarismasi_app/screens/results_screen.dart';
class Quiz extends StatefulWidget
{
const Quiz({super.key});
@override
State<Quiz> createState() => _QuizState();
}
class _QuizState extends State<Quiz> {
//
Kullanıcının seçtiği cevapları biriktireceğimiz liste
List<String> selectedAnswers = [];
var
activeScreen = 'start-screen';
void switchScreen() {
setState(() {
activeScreen
= 'questions-screen';
});
}
void chooseAnswer(String answer) {
selectedAnswers.add(answer);
//
Eğer tüm sorular cevaplandıysa sonuç ekranına geçiş yapıyoruz
if (selectedAnswers.length
== questions.length) {
setState(() {
activeScreen
= 'results-screen';
});
}
}
void restartQuiz() {
setState(() {
selectedAnswers
= []; // Hafızayı temizle
activeScreen
= 'questions-screen'; //
Soruları baştan başlat
});
}
@override
Widget build(BuildContext context) {
//
Şarta bağlı ekran seçimi (Ternary alternatifi temiz
yapı)
Widget screenWidget = StartScreen(switchScreen);
if (activeScreen
== 'questions-screen') {
screenWidget
= QuestionsScreen(onSelectAnswer:
chooseAnswer);
}
if (activeScreen
== 'results-screen') {
screenWidget
= ResultsScreen(
chosenAnswers:
selectedAnswers,
onRestart:
restartQuiz,
);
}
return MaterialApp(
debugShowCheckedModeBanner:
false,
home:
Scaffold(
body: Container(
decoration:
const BoxDecoration(
gradient: LinearGradient(
colors: [
Color.fromARGB(255,
78, 13, 151),
Color.fromARGB(255,
107, 15, 168),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child:
screenWidget, //
Dinamik ekran buraya gömülür
),
),
);
}
}
7.
lib/main.dart (Giriş Kapısı)
Yarışma
motorunu (Quiz widget'ını) başlatan ana
yürütücü.
import 'package:flutter/material.dart';
import 'package:bilgi_yarismasi_app/quiz.dart';
void main() {
runApp(const Quiz());
}
Harika!
İkinci projemizi de başarıyla geride bıraktık. Şimdi, gerçek dünya
uygulamalarında en sık kullanılan özellikleri barındıran Proje 3: Harcama
Takipçisi (Expense Tracker)
Uygulaması'na
geçiyoruz.
Bu
projede; modern Material 3 temalandırmasını (Açık ve Koyu Mod), dinamik ve
performanslı listelemeyi (ListView.builder), veri doğrulamalı form
yönetimini, tarih seçici (showDatePicker) pencerelerini ve elemanları
kaydırarak silmeyi (Dismissible) öğreneceğiz.
📊 Proje 3:
Harcama Takipçisi (Expense Tracker)
📁 Adım 1:
Proje Dosya Yapısı
Uygulamamızın
bileşenleri artacağı için klasör yapımızı düzenli tutuyoruz. lib klasörünüzü şu şekilde oluşturun:
harcama_takip_app/
├──
lib/
│ ├── models/
│ │
└── expense.dart
│ ├── widgets/
│ │
├── expenses_list/
│ │
│ ├── expense_item.dart
│ │
│ └── expenses_list.dart
│ │
└── new_expense.dart
│ ├── expenses.dart
│ └── main.dart
└──
pubspec.yaml
⚙️ Adım 2:
Gerekli Paketlerin Kurulması (pubspec.yaml)
Benzersiz
kimlikler (ID) üretmek ve tarihleri kolayca formatlamak için iki popüler paketi
projemize dahil ediyoruz. pubspec.yaml dosyanızın dependencies bölümünü şu şekilde güncelleyin:
dependencies:
flutter:
sdk: flutter
uuid: ^4.3.3
intl: ^0.19.0
Not:
Paketleri ekledikten sonra terminalde flutter pub
get komutunu çalıştırın.
💻 Adım 3:
Adım Adım Kodlama
1.
lib/models/expense.dart (Harcama Veri Modeli ve Kategori Tanımları)
Harcamalarımızın
sahip olacağı özellikleri ve kategorileri (Enum)
belirlediğimiz veri şablonudur.
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'package:intl/intl.dart';
// Tarihleri
GG/AA/YYYY formatına sokmak için yardımcı nesne
final formatter = DateFormat('dd/MM/yyyy');
const uuid = Uuid();
// Kategorileri sabit
bir küme olarak tanımlıyoruz
enum Category { food, travel, leisure,
work }
// Kategorilere özel
ikon eşleştirmesi yapıyoruz
const categoryIcons = {
Category.food: Icons.restaurant,
Category.travel: Icons.flight_takeoff,
Category.leisure: Icons.movie,
Category.work: Icons.work,
};
class Expense {
Expense({
required this.title,
required this.amount,
required this.date,
required this.category,
}) : id = uuid.v4();
// Her yeni harcamaya otomatik eşsiz bir ID
atar.
final String id;
final String title;
final double amount;
final DateTime date;
final Category category;
//
Formatlanmış tarihi string olarak döndüren metot
String get formattedDate
{
return formatter.format(date);
}
}
2.
lib/widgets/expenses_list/expense_item.dart (Tek Bir Harcama Kartı
Tasarımı)
Listede
görünecek olan her bir harcamanın görsel kart (Card)
tasarımıdır.
import 'package:flutter/material.dart';
import 'package:harcama_takip_app/models/expense.dart';
class ExpenseItem extends StatelessWidget
{
const ExpenseItem(this.expense, {super.key});
final Expense expense;
@override
Widget build(BuildContext context) {
return Card(
child:
Padding(
padding:
const EdgeInsets.symmetric(horizontal:
20, vertical: 16),
child:
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children:
[
Text(
expense.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Row(
children: [
// Harcama tutarını virgülden sonra 2 basamak olacak şekilde
yazdırır ($ veya TL ekleyebilirsiniz)
Text('₺${expense.amount.toStringAsFixed(2)}'),
const Spacer(), // Sol ve sağ tarafı uçlara yaslar, arayı açar.
Row(
children: [
Icon(categoryIcons[expense.category]),
const SizedBox(width: 8),
Text(expense.formattedDate),
],
),
],
),
],
),
),
);
}
}
3.
lib/widgets/expenses_list/expenses_list.dart (Harcama Listesi Motoru)
Harcamaları
ekrana yüksek performanslı bir şekilde çizen ve kaydırarak silme (Dismissible) özelliğini yöneten listedir.
import 'package:flutter/material.dart';
import 'package:harcama_takip_app/models/expense.dart';
import 'package:harcama_takip_app/widgets/expenses_list/expense_item.dart';
class ExpensesList extends StatelessWidget
{
const ExpensesList({
super.key,
required this.expenses,
required this.onRemoveExpense,
});
final List<Expense>
expenses;
final void Function(Expense expense)
onRemoveExpense;
@override
Widget build(BuildContext context) {
//
ListView.builder sadece ekranda görünen elemanları
çizerek performansı korur.
return ListView.builder(
itemCount:
expenses.length,
itemBuilder:
(ctx, index) => Dismissible(
//
Her elemanın kaydırılabilmesi için benzersiz bir ValueKey
alması şarttır.
key:
ValueKey(expenses[index]),
background: Container(
color:
Theme.of(context).colorScheme.error.withAlpha(150),
margin:
EdgeInsets.symmetric(
horizontal:
Theme.of(context).cardTheme.margin?.horizontal
?? 16,
),
alignment:
Alignment.centerRight,
padding:
const EdgeInsets.only(right: 20),
child:
const Icon(Icons.delete, color: Colors.white, size: 30),
),
onDismissed:
(direction) {
onRemoveExpense(expenses[index]);
},
child:
ExpenseItem(expenses[index]),
),
);
}
}
4.
lib/widgets/new_expense.dart (Yeni Harcama Ekleme Penceresi Formu)
Alttan
açılan modal pencere (BottomSheet) içerisinde kullanıcının
veri girmesini sağlayan form alanıdır.
import 'package:flutter/material.dart';
import 'package:harcama_takip_app/models/expense.dart';
class NewExpense extends StatefulWidget
{
const NewExpense({super.key, required this.onAddExpense});
final void Function(Expense expense)
onAddExpense;
@override
State<NewExpense> createState() => _NewExpenseState();
}
class _NewExpenseState extends State<NewExpense> {
//
Input alanlarını kontrol etmek için kontrolcüler
tanımlıyoruz.
final _titleController = TextEditingController();
final _amountController = TextEditingController();
DateTime? _selectedDate;
Category _selectedCategory = Category.leisure;
//
Takvimi açan yardımcı fonksiyon
void _presentDatePicker() async {
final now = DateTime.now();
final firstDate = DateTime(now.year - 1, now.month, now.day);
final pickedDate = await showDatePicker(
context:
context,
initialDate:
now,
firstDate:
firstDate,
lastDate:
now,
);
setState(() {
_selectedDate =
pickedDate;
});
}
//
Form verilerini kontrol eden (Validasyon) fonksiyon
void _submitExpenseData() {
final enteredAmount = double.tryParse(_amountController.text); // Geçersiz metinlerde null döner
final amountIsValid = enteredAmount
!= null && enteredAmount
> 0;
if (_titleController.text.trim().isEmpty || !amountIsValid || _selectedDate == null) {
//
Kullanıcıya hata mesajı gösteren diyalog penceresi
showDialog(
context:
context,
builder:
(ctx) => AlertDialog(
title:
const Text('Geçersiz Girdi'),
message:
const Text('Lütfen geçerli bir başlık, miktar ve tarih seçtiğinizden emin
olun.'),
actions:
[
TextButton(
onPressed: () {
Navigator.pop(ctx);
},
child: const Text('Tamam'),
),
],
),
);
return;
}
//
Sorun yoksa üst widget'a yeni harcamayı gönderiyoruz
widget.onAddExpense(
Expense(
title:
_titleController.text,
amount:
enteredAmount,
date:
_selectedDate!,
category:
_selectedCategory,
),
);
//
Form başarıyla kaydedildikten sonra modalı
kapatıyoruz
Navigator.pop(context);
}
//
Bellek sızıntılarını önlemek için kontrolcüleri temizliyoruz
@override
void dispose() {
_titleController.dispose();
_amountController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding:
const EdgeInsets.fromLTRB(16,
48, 16, 16),
child:
Column(
children:
[
TextField(
controller: _titleController,
maxLength: 50,
decoration: const InputDecoration(label:
Text('Harcama
Başlığı')),
),
Row(
children: [
Expanded(
child: TextField(
controller: _amountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
prefixText: '₺
',
label: Text('Miktar'),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
_selectedDate == null
? 'Tarih Seçilmedi'
: formatter.format(_selectedDate!),
),
IconButton(
onPressed: _presentDatePicker,
icon: const Icon(Icons.calendar_month),
),
],
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
DropdownButton(
value: _selectedCategory,
items: Category.values
.map(
(category) => DropdownMenuItem(
value: category,
child: Text(category.name.toUpperCase()),
),
)
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() {
_selectedCategory
= value;
});
},
),
const Spacer(),
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('İptal'),
),
ElevatedButton(
onPressed: _submitExpenseData,
child: const Text('Kaydet'),
),
],
),
],
),
);
}
}
5.
lib/expenses.dart (Ana Görünüm ve Silineni
Geri Alma Mekanizması)
Uygulamanın
ana ekranıdır. Listeyi çağırır, yeni harcama ekleme modalını
açar ve silinen harcamayı alt bildirim çubuğuyla (SnackBar) geri alma şansı tanır.
import 'package:flutter/material.dart';
import 'package:harcama_takip_app/models/expense.dart';
import 'package:harcama_takip_app/widgets/expenses_list/expenses_list.dart';
import 'package:harcama_takip_app/widgets/new_expense.dart';
class Expenses extends StatefulWidget
{
const Expenses({super.key});
@override
State<Expenses> createState() => _ExpensesState();
}
class _ExpensesState extends State<Expenses> {
//
Örnek başlangıç verileri
final List<Expense>
_registeredExpenses = [
Expense(
title:
'Flutter Kursu',
amount:
299.99,
date:
DateTime.now(),
category:
Category.work,
),
Expense(
title:
'Sinema Bileti',
amount:
150.00,
date:
DateTime.now(),
category:
Category.leisure,
),
];
//
Alttan açılan pencereyi tetikleyen metot
void _openAddExpenseOverlay() {
showModalBottomSheet(
isScrollControlled:
true, //
Modalı tam ekrana yakın açar
context:
context,
builder:
(ctx) => NewExpense(onAddExpense: _addExpense),
);
}
void _addExpense(Expense expense) {
setState(() {
_registeredExpenses.add(expense);
});
}
void _removeExpense(Expense expense) {
final expenseIndex = _registeredExpenses.indexOf(expense);
setState(() {
_registeredExpenses.remove(expense);
});
//
Eski bildirim çubuklarını temizleyip yenisini açıyoruz
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration:
const Duration(seconds: 3),
content:
const Text('Harcama silindi.'),
action:
SnackBarAction(
label:
'Geri Al',
onPressed:
() {
setState(() {
// Silinen elemanı eski sırasına geri yerleştirir
_registeredExpenses.insert(expenseIndex, expense);
});
},
),
),
);
}
@override
Widget build(BuildContext context) {
Widget mainContent = const Center(
child:
Text('Henüz
hiç harcama eklenmedi. Eklemeye başlayın!'),
);
if (_registeredExpenses.isNotEmpty)
{
mainContent
= ExpensesList(
expenses:
_registeredExpenses,
onRemoveExpense:
_removeExpense,
);
}
return Scaffold(
appBar:
AppBar(
title:
const Text('Harcama Takipçisi'),
actions:
[
IconButton(
onPressed: _openAddExpenseOverlay,
icon: const Icon(Icons.add),
),
],
),
body: Column(
children:
[
Expanded(child: mainContent),
],
),
);
}
}
6.
lib/main.dart (Material
3 Açık / Koyu Tema Yapılandırması)
Uygulamanın
renk paletlerini belirlediğimiz ve Material 3 temalandırmasını tam teşekküllü aktif ettiğimiz ana
dosyadır.
import 'package:flutter/material.dart';
import 'package:harcama_takip_app/expenses.dart';
// Açık tema için ana
renk tohumu
var kColorScheme =
ColorScheme.fromSeed(
seedColor:
const Color.fromARGB(255,
99, 59, 255),
);
// Koyu tema için ana
renk tohumu
var kDarkColorScheme
= ColorScheme.fromSeed(
brightness:
Brightness.dark,
seedColor:
const Color.fromARGB(255,
5, 99, 125),
);
void main() {
runApp(
MaterialApp(
debugShowCheckedModeBanner:
false,
//
Koyu Tema Yapılandırması
darkTheme:
ThemeData.dark().copyWith(
colorScheme:
kDarkColorScheme,
cardTheme:
const CardTheme().copyWith(
color:
kDarkColorScheme.secondaryContainer,
margin:
const EdgeInsets.symmetric(horizontal:
16, vertical: 8),
),
elevatedButtonTheme:
ElevatedButtonThemeData(
style:
ElevatedButton.styleFrom(
backgroundColor: kDarkColorScheme.primaryContainer,
foregroundColor: kDarkColorScheme.onPrimaryContainer,
),
),
),
//
Açık Tema Yapılandırması
theme:
ThemeData().copyWith(
colorScheme:
kColorScheme,
appBarTheme:
const AppBarTheme().copyWith(
backgroundColor:
kColorScheme.onPrimaryContainer,
foregroundColor:
kColorScheme.primaryContainer,
),
cardTheme:
const CardTheme().copyWith(
color:
kColorScheme.secondaryContainer,
margin:
const EdgeInsets.symmetric(horizontal:
16, vertical: 8),
),
elevatedButtonTheme:
ElevatedButtonThemeData(
style:
ElevatedButton.styleFrom(
backgroundColor: kColorScheme.primaryContainer,
),
),
textTheme:
ThemeData().textTheme.copyWith(
titleLarge: TextStyle(
fontWeight: FontWeight.bold,
color: kColorScheme.onSecondaryContainer,
fontSize: 16,
),
),
),
themeMode:
ThemeMode.system, // Cihazın kendi ayarlarına göre otomatik gece/gündüz moduna
geçer.
home:
const Expenses(),
),
);
}
Harika!
Harcama Takipçisi uygulamamızı tamamladığımıza göre, bir sonraki aşama olan Modül
5: Duyarlı & Adaptif Kullanıcı Arayüzleri tasarımına geçiyoruz.
Müfredat
akışımıza göre bu modül, yeni bir uygulama başlatmak yerine bir önceki Harcama
Takipçisi (Expense Tracker)
projemizin kaynak kodlarını koruyarak, onu hem yatay (landscape)
modda hem de farklı ekran boyutlarında (tablet, geniş telefonlar vb.)
kusursuz çalışacak şekilde yeniden yapılandırmayı hedefler.
📱 Modül 5
Tasarımı: Duyarlı & Adaptif Harcama Takipçisi
Cihaz
yatay çevrildiğinde klavyenin ekranı kaplaması, arayüzün taşması (RenderFlex overflow) veya bileşenlerin birbirine
girmesi gibi sorunları engellemek için LayoutBuilder
ve MediaQuery mimarisini projemize entegre
ediyoruz.
📁 Adım 1:
Güncellenecek Dosya Yapısı
Mevcut
projenizdeki şu iki ana dosyayı modüler ve esnek hale getirmek üzere baştan
tasarlayacağız:
💻 Adım 2:
Adım Adım Adaptif Kodlama
1.
lib/widgets/new_expense.dart (Duyarlı Form Penceresi)
Kullanıcı
yatay modda form açtığında, klavye (Soft Keyboard) alttan gelerek girdileri
kapatabilir. Bu dosyada MediaQuery ile klavye yüksekliğini
dinamik alıp formu yukarı kaydırılabilir yapacağız ve LayoutBuilder ile girdileri yan yana dizeceğiz.
import 'package:flutter/material.dart';
import 'package:harcama_takip_app/models/expense.dart';
class NewExpense extends StatefulWidget
{
const NewExpense({super.key, required this.onAddExpense});
final void Function(Expense expense)
onAddExpense;
@override
State<NewExpense> createState() => _NewExpenseState();
}
class _NewExpenseState extends State<NewExpense> {
final _titleController = TextEditingController();
final _amountController = TextEditingController();
DateTime? _selectedDate;
Category _selectedCategory = Category.leisure;
void _presentDatePicker() async {
final now = DateTime.now();
final firstDate = DateTime(now.year - 1, now.month, now.day);
final pickedDate = await showDatePicker(
context:
context,
initialDate:
now,
firstDate:
firstDate,
lastDate:
now,
);
setState(() {
_selectedDate =
pickedDate;
});
}
void _submitExpenseData() {
final enteredAmount = double.tryParse(_amountController.text);
final amountIsValid = enteredAmount
!= null && enteredAmount
> 0;
if (_titleController.text.trim().isEmpty || !amountIsValid || _selectedDate == null) {
showDialog(
context:
context,
builder:
(ctx) => AlertDialog(
title:
const Text('Geçersiz Girdi'),
message:
const Text('Lütfen alanları eksiksiz doldurunuz.'),
actions:
[
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Tamam')),
],
),
);
return;
}
widget.onAddExpense(
Expense(
title:
_titleController.text,
amount:
enteredAmount,
date:
_selectedDate!,
category:
_selectedCategory,
),
);
Navigator.pop(context);
}
@override
void dispose() {
_titleController.dispose();
_amountController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
//
1. Klavyenin ekranda kapladığı alt boşluğu (inset)
dinamik olarak alıyoruz.
final keyboardSpace = MediaQuery.of(context).viewInsets.bottom;
//
2. LayoutBuilder sayesinde üst widget'ın
bize sunduğu max genişliği kontrol ediyoruz.
return LayoutBuilder(builder: (ctx, constraints) {
final width = constraints.maxWidth;
return SizedBox(
height:
double.infinity, // BottomSheet'in tam ekrana
esnemesini sağlar
child:
SingleChildScrollView(
child:
Padding(
// Alt padding'e klavye boşluğunu
ekleyerek formu otomatik yukarı itiyoruz
padding: EdgeInsets.fromLTRB(16,
16, 16, keyboardSpace
+ 16),
child: Column(
children: [
// EKRAN GENİŞLİĞİ 600px'DEN BÜYÜKSE (YATAY MOD)
if (width > 600)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
controller: _titleController,
maxLength: 50,
decoration: const InputDecoration(label:
Text('Harcama
Başlığı')),
),
),
const SizedBox(width: 24),
Expanded(
child: TextField(
controller: _amountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
prefixText: '₺
',
label: Text('Miktar'),
),
),
),
],
)
else // DIKEY MOD STANDART
GÖRÜNÜM
TextField(
controller: _titleController,
maxLength: 50,
decoration: const InputDecoration(label:
Text('Harcama
Başlığı')),
),
// İKİNCİ SATIR (Kategori, Tarih Seçici ve Butonlar)
if (width > 600)
Row(
children: [
DropdownButton<Category>(
value: _selectedCategory,
items: Category.values
.map((cat) => DropdownMenuItem(value: cat, child: Text(cat.name.toUpperCase())))
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() => _selectedCategory = value);
},
),
const SizedBox(width: 24),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(_selectedDate
== null ? 'Tarih
Seçilmedi'
:
formatter.format(_selectedDate!)),
IconButton(onPressed: _presentDatePicker, icon: const Icon(Icons.calendar_month)),
],
),
),
],
)
else
Row(
children: [
Expanded(
child: TextField(
controller: _amountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(prefixText:
'₺ ', label: Text('Miktar')),
),
),
const SizedBox(width: 16),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(_selectedDate
== null ? 'Tarih
Seçilmedi'
:
formatter.format(_selectedDate!)),
IconButton(onPressed: _presentDatePicker, icon: const Icon(Icons.calendar_month)),
],
),
),
],
),
const SizedBox(height: 16),
// AKSİYON BUTONLARI
Row(
children: [
if (width <= 600)
DropdownButton<Category>(
value: _selectedCategory,
items: Category.values
.map((cat) => DropdownMenuItem(value: cat, child: Text(cat.name.toUpperCase())))
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() => _selectedCategory = value);
},
),
const Spacer(),
TextButton(onPressed: () => Navigator.pop(context), child: const Text('İptal')),
ElevatedButton(onPressed: _submitExpenseData,
child: const Text('Kaydet')),
],
),
],
),
),
),
);
});
}
}
2.
lib/expenses.dart (Yatay/Dikey Duyarlı Ana
Görünüm)
Bu
sayfada, cihazın yatay mı dikey mi durduğunu MediaQuery.of(context).orientation ile kontrol edeceğiz. Dikey
modda listeyi Column ile alt alta, yatay modda ise grafikleri ve listeyi Row
kullanarak yan yana dizeceğiz.
import 'package:flutter/material.dart';
import 'package:harcama_takip_app/models/expense.dart';
import 'package:harcama_takip_app/widgets/expenses_list/expenses_list.dart';
import 'package:harcama_takip_app/widgets/new_expense.dart';
class Expenses extends StatefulWidget
{
const Expenses({super.key});
@override
State<Expenses> createState() => _ExpensesState();
}
class _ExpensesState extends State<Expenses> {
final List<Expense>
_registeredExpenses = [
Expense(title: 'Flutter Kursu', amount: 299.99,
date: DateTime.now(), category: Category.work),
Expense(title: 'Sinema Bileti', amount: 150.00,
date: DateTime.now(), category: Category.leisure),
];
void _openAddExpenseOverlay() {
showModalBottomSheet(
useSafeArea:
true, //
Çentikli ekran korumasını modal için de aktif eder
isScrollControlled:
true,
context:
context,
builder:
(ctx) => NewExpense(onAddExpense: _addExpense),
);
}
void _addExpense(Expense expense) {
setState(() {
_registeredExpenses.add(expense);
});
}
void _removeExpense(Expense expense) {
final expenseIndex = _registeredExpenses.indexOf(expense);
setState(() {
_registeredExpenses.remove(expense);
});
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration:
const Duration(seconds: 3),
content:
const Text('Harcama silindi.'),
action:
SnackBarAction(
label:
'Geri Al',
onPressed:
() {
setState(() {
_registeredExpenses.insert(expenseIndex, expense);
});
},
),
),
);
}
@override
Widget build(BuildContext context) {
//
Cihazın anlık genişlik değerini alıyoruz
final width = MediaQuery.of(context).size.width;
Widget mainContent = const Center(
child:
Text('Henüz
hiç harcama eklenmedi. Eklemeye başlayın!'),
);
if (_registeredExpenses.isNotEmpty)
{
mainContent
= ExpensesList(
expenses:
_registeredExpenses,
onRemoveExpense:
_removeExpense,
);
}
return Scaffold(
appBar:
AppBar(
title:
const Text('Harcama Takipçisi'),
actions:
[
IconButton(onPressed: _openAddExpenseOverlay,
icon: const Icon(Icons.add)),
],
),
//
SafeArea: Çentik veya alt navigasyon çubuklarının
içeriği kesmesini önler.
body: SafeArea(
//
Cihaz genişliği 600px'den küçükse dikey tasarım (Column),
büyükse yatay tasarım (Row)
child:
width < 600
? Column(
children: [
// İlerleyen modüllerde buraya eklenecek Chart (Grafik) widget'ı alanı
const Padding(
padding: EdgeInsets.all(16.0),
child: Text('Grafik Alanı (Dikey Mod)'),
),
Expanded(child:
mainContent),
],
)
: Row(
children: [
// Yatay modda grafik sol tarafta yer alır
const Expanded(
child: Center(
child: Text('Grafik Alanı (Yatay Mod)'),
),
),
// Liste sağ tarafta yer alır
Expanded(child:
mainContent),
],
),
),
);
}
}
Harika!
Her şeyin yolunda gitmesine çok sevindim. Şimdi, mimari açıdan en önemli dönüm
noktalarından birine, Modül 8: Global Durum Yönetimi (State
Management - Riverpod)
aşamasına geçiyoruz.
Müfredat
akışımıza göre bu aşamada, bir önceki Yemek Tarifleri Uygulaması (Meals App) projemizi bozmadan
üzerine inşa edeceğiz. Uygulamamıza Tab Tabanlı Gezinme (Alt Menü), Favorilere
Ekleme Mekanizması ve bu favori durumunu tüm ekranlardan anlık olarak
yönetebileceğimiz profesyonel Riverpod
paketini entegre edeceğiz.
🌊 Modül 8
Tasarımı: Riverpod ile Global State
Yönetimi
📁 Adım 1:
Güncellenecek ve Yeni Eklenecek Dosya Yapısı
Mevcut
projenizin lib klasörüne durum yönetimi (providers) ve yeni alt menü ekranını
ekleyerek yapıyı şu şekle getiriyoruz:
yemek_tarifleri_app/
├──
lib/
│ ├── models/
│ │
├── category.dart
│ │
└── meal.dart
│ ├── data/
│ │
└── dummy_data.dart
│ ├── providers/
│ │
└── favorites_provider.dart
│ ├── screens/
│ │
├── categories_screen.dart
│ │
├── meals_screen.dart
│ │
├── meal_details_screen.dart
│ │
└── tabs_screen.dart
│ ├── widgets/
│ │
├── category_grid_item.dart
│ │
└── meal_item.dart
│ └── main.dart
└──
pubspec.yaml
⚙️ Adım 2: Riverpod Paketinin Kurulması (pubspec.yaml)
Flutter dünyasının en modern ve güvenli durum yönetimi aracı olan flutter_riverpod paketini pubspec.yaml dosyanızın dependencies alanına ekleyin:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
Not:
Paket eklemesinden sonra terminalde flutter pub
get komutunu çalıştırın.
💻 Adım 3:
Adım Adım Kodlama
1.
lib/providers/favorites_provider.dart (Merkezi Favori Yönetim
Deposu)
Tüm
uygulamadaki favori yemek listesini tek bir merkezden yöneten ve güncelleyen Riverpod deposudur.
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yemek_tarifleri_app/models/meal.dart';
// StateNotifier:
Zamanla değişen, listeye eleman ekleme/çıkarma yapılan durumları yönetir.
class FavoriteMealsNotifier extends StateNotifier<List<Meal>> {
//
Başlangıçta boş bir liste ([]) ile durumu (state)
başlatıyoruz.
FavoriteMealsNotifier() : super([]);
//
Favoriye ekleme veya çıkarma işini yapan metot
bool
toggleMealFavoriteStatus(Meal meal) {
//
Yemeğin zaten favorilerde olup olmadığını kontrol ediyoruz
final mealIsFavorite = state.contains(meal);
if (mealIsFavorite)
{
//
Dart'ta bellek optimizasyonu için durumu doğrudan 'state.remove()' yapamayız.
//
Listeyi filtreleyerek (where) yemeğin olmadığı YENİ
BİR LİSTE adresi atamalıyız.
state
= state.where((m) => m.id != meal.id).toList();
return false; // Favorilerden
çıkarıldı
} else {
//
Mevcut listenin elemanlarını açıp (...) yeni yemeği ekleyerek YENİ BİR LİSTE
oluşturuyoruz.
state
= [...state, meal];
return true; // Favorilere eklendi
}
}
}
// Global olarak tüm
uygulamanın erişebileceği sağlayıcı (Provider) tanımı
final favoriteMealsProvider
= StateNotifierProvider<FavoriteMealsNotifier,
List<Meal>>((ref)
{
return FavoriteMealsNotifier();
});
2.
lib/screens/meal_details_screen.dart (Riverpod
Bağlantılı Detay Ekranı)
Bu
sayfada, kullanıcının favori ikonuna basmasıyla Riverpod
deposundaki fonksiyonu tetikleyeceğiz ve butonun ikonunu anlık (açık/kapalı
kalp) değiştireceğiz. Bunun için widget türümüzü ConsumerWidget yapıyoruz.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; //
Riverpod entegrasyonu
import 'package:yemek_tarifleri_app/models/meal.dart';
import 'package:yemek_tarifleri_app/providers/favorites_provider.dart';
// ConsumerWidget:
Riverpod depolarını dinleyebilen (read/watch) özel bir StatelessWidget'tır.
class MealDetailsScreen extends ConsumerWidget
{
const MealDetailsScreen({super.key, required this.meal});
final Meal meal;
@override
//
ConsumerWidget build metodu
ekstra bir 'WidgetRef ref'
parametresi alır.
Widget build(BuildContext context, WidgetRef ref) {
//
Global favori listesini izliyoruz (watch)
final favoriteMeals = ref.watch(favoriteMealsProvider);
//
Bu yemek favori listesinde var mı?
final isFavorite = favoriteMeals.contains(meal);
return Scaffold(
appBar:
AppBar(
title:
Text(meal.title),
actions:
[
IconButton(
onPressed: () {
// ref.read:
Tek seferlik tetikleme işlemleri için kullanılır (Notifier'a
erişim).
final wasAdded = ref
.read(favoriteMealsProvider.notifier)
.toggleMealFavoriteStatus(meal);
// Kullanıcıya alt bildirim çubuğu gösteriyoruz
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
wasAdded ? 'Yemek favorilere eklendi!' : 'Yemek
favorilerden kaldırıldı.',
),
),
);
},
// Duruma göre için dolu veya boş kalp ikonu gösterilir
icon: Icon(isFavorite ? Icons.star : Icons.star_border),
)
],
),
body: SingleChildScrollView(
child:
Column(
children:
[
Image.network(meal.imageUrl, height:
300, width: double.infinity, fit: BoxFit.cover),
const SizedBox(height: 14),
Text(
'Malzemeler',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
for (final ingredient in
meal.ingredients)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16,
vertical: 4),
child: Text(ingredient, textAlign: TextAlign.center),
),
const SizedBox(height: 24),
Text(
'Hazırlanışı',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
for (final step in meal.steps)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16,
vertical: 8),
child: Text(step, textAlign: TextAlign.center, style: const TextStyle(height:
1.5)),
),
],
),
),
);
}
}
3.
lib/screens/tabs_screen.dart (Alt Navigasyon ve Ekran Birleştirici)
Ekranın
altında sekmeler (BottomNavigationBar) sunarak kullanıcının
Kategoriler ve Favoriler arasında geçiş yapmasını sağlayan ana yönetim üssüdür.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:yemek_tarifleri_app/providers/favorites_provider.dart';
import 'package:yemek_tarifleri_app/screens/categories_screen.dart';
import 'package:yemek_tarifleri_app/screens/meals_screen.dart';
class TabsScreen extends ConsumerStatefulWidget
{
const TabsScreen({super.key});
@override
ConsumerState<TabsScreen> createState() => _TabsScreenState();
}
// ConsumerStatefulWidget
kullandığımız için durum sınıfı ConsumerState'ten
türetilir.
class _TabsScreenState extends ConsumerState<TabsScreen> {
int
_selectedPageIndex = 0;
void _selectPage(int index) {
setState(() {
_selectedPageIndex
= index;
});
}
@override
Widget build(BuildContext context) {
//
1. Sekme: Kategoriler Ekranı (Eski Scaffold yapısını
içeride temiz tutacağız)
Widget activePage = const CategoriesScreen();
var
activePageTitle = 'Kategoriler';
//
2. Sekme: Favoriler Ekranı seçildiyse
if (_selectedPageIndex
== 1) {
//
Riverpod deposundan anlık favori yemekleri çekip MealsScreen'e paslıyoruz.
final favoriteMeals = ref.watch(favoriteMealsProvider);
activePage
= MealsScreen(
title:
'Favorileriniz',
meals:
favoriteMeals,
);
activePageTitle
= 'Favorilerim';
}
return Scaffold(
appBar:
_selectedPageIndex == 0
? null // Kategoriler
ekranının kendi appbar'ı olduğu için burada
gizliyoruz.
: AppBar(title:
Text(activePageTitle)),
body: activePage,
bottomNavigationBar:
BottomNavigationBar(
onTap:
_selectPage,
currentIndex:
_selectedPageIndex, // Aktif olan sekmeyi parlatır
items:
const [
BottomNavigationBarItem(icon: Icon(Icons.set_meal), label: 'Kategoriler'),
BottomNavigationBarItem(icon: Icon(Icons.star), label: 'Favoriler'),
],
),
);
}
}
4.
lib/screens/categories_screen.dart (Temizlenmiş Kategoriler Ekranı)
Bu
dosya artık alt menü yapısına (Tabs) gömüleceği için
kendi dış Scaffold iskeletini kaldırıp sadece gövde (body) tasarımını sunacak şekilde
sadeleştirilir.
import 'package:flutter/material.dart';
import 'package:yemek_tarifleri_app/data/dummy_data.dart';
import 'package:yemek_tarifleri_app/models/category.dart';
import 'package:yemek_tarifleri_app/screens/meals_screen.dart';
import 'package:yemek_tarifleri_app/widgets/category_grid_item.dart';
class CategoriesScreen extends StatelessWidget
{
const CategoriesScreen({super.key});
void _selectCategory(BuildContext context, Category category) {
final filteredMeals = dummyMeals.where((meal)
=> meal.categories.contains(category.id)).toList();
Navigator.of(context).push(
MaterialPageRoute(
builder:
(ctx) => MealsScreen(title: category.title, meals: filteredMeals),
),
);
}
@override
Widget build(BuildContext context) {
//
Scaffold kaldırıldı, doğrudan GridView
dönerek TabsScreen'e uyumlu hale getirildi.
return GridView(
padding:
const EdgeInsets.all(24),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
2,
childAspectRatio:
3 / 2,
crossAxisSpacing:
20,
mainAxisSpacing:
20,
),
children:
[
for (final category in
dummyCategories)
CategoryGridItem(
category: category,
onSelectCategory: () => _selectCategory(context, category),
)
],
);
}
}
5.
lib/main.dart (ProviderScope
ile Uygulamayı Sarmalama)
Riverpod durum yöneticisinin tüm projede çalışabilmesi için tüm
uygulamayı ProviderScope widget'ı
ile sarmalamamız şarttır. Giriş sayfasını da TabsScreen olarak değiştiriyoruz.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; //
Paket dahil edildi
import 'package:yemek_tarifleri_app/screens/tabs_screen.dart';
final theme = ThemeData(
useMaterial3: true,
colorScheme:
ColorScheme.fromSeed(
brightness:
Brightness.dark,
seedColor:
const Color.fromARGB(255,
131, 57, 0),
),
);
void main() {
runApp(
//
ProviderScope: Riverpod'ın
tüm durum depolarını sakladığı evrensel kapsayıcıdır.
const ProviderScope(
child:
App(),
),
);
}
class App extends StatelessWidget
{
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner:
false,
theme:
theme,
home:
const TabsScreen(), // Uygulama alt menülü sekme ekranıyla başlar
);
}
}
Harika!
Global durum yönetimini (Riverpod) başarıyla
kavradığımıza göre, şimdi uygulamalarımızı dış dünyaya açma vaktimiz geldi.
Sırada Proje 5: Alışveriş Listesi Uygulaması (Shopping
List App) var.
Bu
projede; verileri sadece cihaz hafızasında tutmak yerine, internet üzerindeki
canlı bir veri tabanına (Firebase Realtime Database) bağlayacağız. HTTP paketini
kullanarak arka planda asenkron veri göndermeyi (POST), veri çekmeyi (GET) ve silmeyi (DELETE), işlem sürerken yüklenme
animasyonu (CircularProgressIndicator) göstermeyi ve hata
yönetimini sıfırdan inşa edeceğiz.
🌐 Proje 5:
Alışveriş Listesi (HTTP & Backend Entegrasyonu)
📁 Adım 1:
Proje Dosya Yapısı
Uygulamamız
internet servisleriyle konuşacağı için veri tabanından gelen JSON datalarını
nesnelere dönüştürecek bir model yapısı kuruyoruz. lib klasörünüzü şu şekilde
yapılandırın:
alisveris_listesi_backend_app/
├──
lib/
│ ├── models/
│ │
├── category.dart
│ │
└── grocery_item.dart
│ ├── data/
│ │
└── categories.dart
│ ├── widgets/
│ │
├── grocery_list.dart
│ │
└── new_item.dart
│ └── main.dart
└──
pubspec.yaml
⚙️ Adım 2:
Gerekli Paketlerin Kurulması (pubspec.yaml)
İnternet
ağ isteklerini yapabilmek için resmi http paketini projemize ekliyoruz. pubspec.yaml dosyanızın dependencies altına şu satırı ekleyin:
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
Not:
Paket listesini kaydettikten sonra terminalde flutter pub
get komutunu çalıştırın.
☁️ Adım 3: Firebase Kurulum Notu (Ön Hazırlık)
Bu
projede veri tabanı olarak Firebase Realtime Database kullanacağız.
💻 Adım 4:
Adım Adım Kodlama
1.
lib/models/category.dart (Kategori Modeli)
Ürünlerin
ait olacağı kategorileri (Örn: Sebze, Meyve, Süt
Ürünü) tutan modeldir.
import 'package:flutter/material.dart';
enum Categories { vegetables, fruit, meat, dairy, carbs,
sweets, spices, convenience, hygiene, other }
class Category {
const Category(this.title, this.color);
final String title;
final Color color;
}
2.
lib/data/categories.dart (Sabit Kategori Verileri)
Uygulamada
ürün seçerken kullanılacak renkli kategori listesidir.
import 'package:flutter/material.dart';
import 'package:alisveris_listesi_backend_app/models/category.dart';
const categories = {
Categories.vegetables:
Category('Sebzeler',
Color.fromARGB(255, 0, 255, 128)),
Categories.fruit: Category('Meyveler',
Color.fromARGB(255, 255, 102, 0)),
Categories.meat: Category('Et & Şarküteri', Color.fromARGB(255,
255, 0, 0)),
Categories.dairy: Category('Süt Ürünleri', Color.fromARGB(255,
0, 204, 255)),
Categories.carbs: Category('Karbonhidratlar', Color.fromARGB(255,
204, 153, 102)),
Categories.sweets: Category('Tatlılar',
Color.fromARGB(255, 255, 102, 204)),
Categories.spices: Category('Baharatlar', Color.fromARGB(255,
204, 204, 0)),
Categories.convenience:
Category('Hazır Gıda', Color.fromARGB(255,
153, 102, 255)),
Categories.hygiene:
Category('Temizlik',
Color.fromARGB(255, 0, 102, 204)),
Categories.other: Category('Diğer',
Color.fromARGB(255, 128, 128, 128)),
};
3.
lib/models/grocery_item.dart (Alışveriş Ürünü Modeli)
Listeye
eklenecek her bir ürünün şablonudur.
import 'package:alisveris_listesi_backend_app/models/category.dart';
class GroceryItem {
const GroceryItem({
required this.id,
required this.name,
required this.quantity,
required this.category,
});
final String id;
final String name;
final int quantity;
final Category category;
}
4.
lib/widgets/new_item.dart (HTTP POST - Veri Gönderme Formu)
Kullanıcının
yeni bir ürün adı ve adeti girerek internetteki veri tabanına POST
isteği ile kayıt yaptığı form ekranıdır.
import 'dart:convert'; // JSON
dönüşümleri için gerekli paket
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; // HTTP ağ paketi
import 'package:alisveris_listesi_backend_app/data/categories.dart';
import 'package:alisveris_listesi_backend_app/models/category.dart';
import 'package:alisveris_listesi_backend_app/models/grocery_item.dart';
class NewItem extends StatefulWidget
{
const NewItem({super.key});
@override
State<NewItem> createState() => _NewItemState();
}
class _NewItemState extends State<NewItem> {
final _formKey = GlobalKey<FormState>();
var
_enteredName = '';
var
_enteredQuantity = 1;
var
_selectedCategory = categories[Categories.vegetables]!;
var
_isSending = false; // Gönderiliyor
animasyonu kontrolü
void _saveItem() async {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
setState(() {
_isSending = true; //
Yüklenme çubuğunu aktif et
});
//
Kendi Firebase adresinizi ve sonuna oluşturmak
istediğiniz tablo adını (.json
ile) yazın
final url = Uri.https('://firebaseio.com', 'shopping-list.json');
//
http.post: Uzak sunucuya veri gönderme metodu
final response = await http.post(
url,
headers:
{'Content-Type': 'application/json'},
body: json.encode({
'name': _enteredName,
'quantity': _enteredQuantity,
'category': _selectedCategory.title,
}),
);
//
Firebase başarılı kayıt sonrası bize otomatik
ürettiği benzersiz ID içeren bir JSON döner.
final Map<String,
dynamic> resData = json.decode(response.body);
if (!mounted) return; //
Eğer kullanıcı istek sürerken sayfadan çıktıysa çökmesini önler.
//
Geriye eklenen elemanı döndürerek ana ekran listesine anlık eklenmesini
sağlıyoruz.
Navigator.of(context).pop(
GroceryItem(
id: resData['name'], //
Firebase'in ürettiği benzersiz ID
name: _enteredName,
quantity:
_enteredQuantity,
category:
_selectedCategory,
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar:
AppBar(title: const Text('Yeni Ürün Ekle')),
body: Padding(
padding:
const EdgeInsets.all(12),
child:
Form(
key:
_formKey,
child:
Column(
children: [
TextFormField(
maxLength: 50,
decoration: const InputDecoration(label:
Text('Ürün
Adı')),
validator: (value) {
if (value == null || value.isEmpty
|| value.trim().length
<= 1) {
return 'Lütfen
geçerli bir ürün adı giriniz (Min 2 karakter).';
}
return null;
},
onSaved: (value) => _enteredName = value!,
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextFormField(
decoration: const InputDecoration(label:
Text('Adet')),
keyboardType: TextInputType.number,
initialValue: _enteredQuantity.toString(),
validator: (value) {
if (value == null || value.isEmpty
|| int.tryParse(value) == null || int.tryParse(value)! <= 0) {
return 'Geçerli
bir sayı giriniz.';
}
return null;
},
onSaved: (value) => _enteredQuantity = int.parse(value!),
),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<Category>(
value: _selectedCategory,
items: [
for (final category in
categories.entries)
DropdownMenuItem(
value: category.value,
child: Row(
children: [
Container(width: 16, height: 16,
color: category.value.color),
const SizedBox(width: 6),
Text(category.value.title),
],
),
),
],
onChanged: (value) => setState(() => _selectedCategory
= value!),
),
),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isSending ? null : () => Navigator.pop(context),
child: const Text('İptal'),
),
ElevatedButton(
onPressed: _isSending ? null : _saveItem,
child: _isSending
? const SizedBox(width: 16,
height: 16, child: CircularProgressIndicator())
: const Text('Ürünü Ekle'),
),
],
)
],
),
),
),
);
}
}
5.
lib/widgets/grocery_list.dart (HTTP GET & DELETE - Ana
Ekran)
Açıldığında
veri tabanındaki tüm ürünleri GET isteğiyle çeken, yüklenirken bekleme
emojisi/çubuğu gösteren ve eleman kaydırıldığında veri tabanından DELETE
ile silen ana yönetim merkezidir.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:alisveris_listesi_backend_app/data/categories.dart';
import 'package:alisveris_listesi_backend_app/models/grocery_item.dart';
import 'package:alisveris_listesi_backend_app/widgets/new_item.dart';
class GroceryList extends StatefulWidget
{
const GroceryList({super.key});
@override
State<GroceryList> createState() => _GroceryListState();
}
class _GroceryListState extends State<GroceryList> {
List<GroceryItem> _groceryItems =
[];
var
_isLoading = true; // Veri yükleniyor mu
kontrolü
String? _error; // Hata
mesajı hafızası
@override
void initState() {
super.initState();
_loadItems(); // Sayfa
açılır açılmaz internetten verileri çekiyoruz.
}
//
HTTP GET - İnternetten Veri Çekme Fonksiyonu
void _loadItems() async {
final url = Uri.https('://firebaseio.com', 'shopping-list.json');
try {
final response = await http.get(url);
//
Sunucu hatası kontrolü (Örn: 404 veya 500 hataları)
if (response.statusCode
>= 400) {
setState(() {
_error = 'Veriler çekilirken bir hata oluştu. Lütfen daha sonra tekrar
deneyin.';
_isLoading
= false;
});
return;
}
//
Veri tabanı tamamen boşsa Firebase bize 'null' string'i döner.
if (response.body
== 'null') {
setState(() {
_isLoading
= false;
});
return;
}
final Map<String,
dynamic> listData =
json.decode(response.body);
final List<GroceryItem>
loadedItems = [];
//
Gelen karmaşık JSON haritasını tek tek dönüp nesne listemize dönüştürüyoruz.
for (final item in
listData.entries) {
//
String kategoriyi bulmak için eşleştirme yapıyoruz
final category = categories.entries
.firstWhere((catItem) =>
catItem.value.title == item.value['category'])
.value;
loadedItems.add(
GroceryItem(
id: item.key,
name: item.value['name'],
quantity: item.value['quantity'],
category: category,
),
);
}
setState(() {
_groceryItems
= loadedItems;
_isLoading = false;
});
} catch (err) {
//
İnternet olmaması gibi ekstrem durumlarda catch bloğu
tetiklenir
setState(() {
_error = 'Bağlantı hatası! İnternetinizi kontrol edin.';
_isLoading = false;
});
}
}
//
Sayfaya git ve formdan dönen veriyi yakala
void _addItem() async {
final newItem = await Navigator.of(context).push<GroceryItem>(
MaterialPageRoute(builder: (ctx)
=> const NewItem()),
);
if (newItem == null) return;
//
Sayfaya geri dönüldüğünde tüm listeyi tekrar internetten çekmek yerine,
//
dönen tek objeyi yerel listeye ekleyerek internet kotasını ve performansı
koruyoruz.
setState(() {
_groceryItems.add(newItem);
});
}
//
HTTP DELETE - İnternetten Veri Silme Fonksiyonu
void _removeItem(GroceryItem item) async {
final index = _groceryItems.indexOf(item);
setState(() {
_groceryItems.remove(item); //
Kullanıcıya hızlı tepki vermek için yerelden hemen siliyoruz (Optimistic UI)
});
//
Silinecek elemanın ID'sini URL sonuna ekleyerek
DELETE isteği atıyoruz.
final url = Uri.https('://firebaseio.com', 'shopping-list/${item.id}.json');
final response = await http.delete(url);
//
Eğer sunucuda silme işlemi başarısız olduysa elemanı listeye eski sırasıyla
geri yüklüyoruz.
if (response.statusCode
>= 400) {
setState(() {
_groceryItems.insert(index, item);
});
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ürün sunucudan silinemedi!')),
);
}
}
@override
Widget build(BuildContext context) {
Widget mainContent = const Center(child: Text('Listenizde henüz hiç
ürün yok.'));
if (_isLoading) {
mainContent
= const Center(child: CircularProgressIndicator()); // Dönüş animasyonu
}
if (_groceryItems.isNotEmpty)
{
mainContent
= ListView.builder(
itemCount:
_groceryItems.length,
itemBuilder:
(ctx, index) => Dismissible(
key:
ValueKey(_groceryItems[index].id),
onDismissed:
(direction) => _removeItem(_groceryItems[index]),
background: Container(color: Colors.red.withAlpha(150)),
child:
ListTile(
title: Text(_groceryItems[index].name),
leading: Container(width: 24, height: 24,
color: _groceryItems[index].category.color),
trailing: Text(_groceryItems[index].quantity.toString()),
),
),
);
}
if (_error != null) {
mainContent
= Center(child: Text(_error!)); //
Hata mesajı ekranı
}
return Scaffold(
appBar:
AppBar(
title:
const Text('Alışveriş Listem (Bulut Tabanlı)'),
actions:
[
IconButton(onPressed: _addItem,
icon: const Icon(Icons.add)),
],
),
body: mainContent,
);
}
}
6.
lib/main.dart (Giriş Kapısı)
Koyu
malzeme tasarımıyla uygulamayı başlatan kod bloğudur.
import 'package:flutter/material.dart';
import 'package:alisveris_listesi_backend_app/widgets/grocery_list.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget
{
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner:
false,
title:
'Alışveriş Bulut Takip',
theme:
ThemeData.dark().copyWith(
useMaterial3: true,
colorScheme:
ColorScheme.fromSeed(
brightness:
Brightness.dark,
seedColor:
const Color.fromARGB(255,
147, 229, 250),
surface:
const Color.fromARGB(255,
30, 38, 44),
),
),
home:
const GroceryList(),
);
}
}
Harika!
Bulut veri tabanı ve HTTP ağ bağlantılarını tamamladığımıza göre, şimdi cihazın
kendi donanım yeteneklerini sonuna kadar kullanacağımız Proje 6: Favori
Mekanlarım Uygulaması (Native Device Features) projesine geçiyoruz.
Bu
projede; telefonun Kamerasına erişerek fotoğraf çekmeyi, cihazın GPS
donanımını kullanarak anlık harita konumunu yakalamayı, bu verileri yerel
bir SQLite Veri Tabanında (sqflite)
internet bağımsız saklamayı ve Riverpod ile
yönetmeyi öğreneceğiz.
📸 Proje 6:
Favori Mekanlarım (Native Device Features)
📁 Adım 1:
Proje Dosya Yapısı
Donanım
ve yerel veri tabanı kodları içereceği için katmanlı mimariyi çok sıkı
kuruyoruz. lib klasörünüzü şu şekilde
oluşturun:
favori_mekanlar_app/
├──
lib/
│ ├── models/
│ │
└── place.dart
│ ├── providers/
│ │
└── user_places.provider.dart
│ ├── screens/
│ │
├── places_screen.dart
│ │
├── add_place_screen.dart
│ │
└── place_detail_screen.dart
│ ├── widgets/
│ │
├── image_input.dart
│ │
└── location_input.dart
│ └── main.dart
└──
pubspec.yaml
⚙️ Adım 2:
Gerekli Paketlerin Kurulması (pubspec.yaml)
Kamera,
konum ve SQL veri tabanı için gerekli güncel paketleri projemize dahil
ediyoruz. pubspec.yaml dosyanızın dependencies alanını şu şekilde güncelleyin:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1
uuid: ^4.3.3
image_picker: ^1.0.7
location: ^5.0.3
sqflite: ^2.3.2
path_provider: ^2.1.2
path: ^1.9.0
Not:
Paket eklemesinden sonra terminalde flutter pub
get komutunu çalıştırın.
🔐 Adım 3:
İşletim Sistemi İzin Ayarları (Kritik Adım)
Kamera
ve GPS donanımlarına erişebilmek için telefon işletim sistemlerinden izin
istemeliyiz.
·
<uses-permission android:name="android.permission.CAMERA" />
·
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
·
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
·
<key>NSCameraUsageDescription</key>
·
<string>Mekan fotoğrafı çekebilmek için
kamera izni gerekiyor.</string>
·
<key>NSLocationWhenInUseUsageDescription</key>
·
<string>Mekanın harita konumunu alabilmek
için konum izni gerekiyor.</string>
💻 Adım 4:
Adım Adım Kodlama
1.
lib/models/place.dart (Gelişmiş Mekan Modeli)
Çekilen
fotoğraf dosyasını (File) ve konumun koordinatlarını bir arada tutan veri
şablonudur.
import 'dart:io';
import 'package:uuid/uuid.dart';
const uuid = Uuid();
class PlaceLocation {
const PlaceLocation({
required this.latitude,
required this.longitude,
required this.address,
});
final double latitude;
final double longitude;
final String address;
}
class Place {
Place({
required this.title,
required this.image,
required this.location,
String? id,
}) : id = id ??
uuid.v4(); // Eğer SQL'den ID
gelirse onu kullanır, gelmezse yeni üretir.
final String id;
final String title;
final File image; // Cihaz hafızasındaki resim dosyası
final PlaceLocation location;
}
2.
lib/widgets/image_input.dart (Kamera ile Fotoğraf Çekme Bileşeni)
Telefon
kamerasını tetikleyen, çekilen fotoğrafı ekranda önizleyen
ve form alanına teslim eden widget'tır.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
class ImageInput extends StatefulWidget
{
const ImageInput({super.key, required this.onPickImage});
final void Function(File image) onPickImage;
@override
State<ImageInput> createState() => _ImageInputState();
}
class _ImageInputState extends State<ImageInput> {
File? _selectedImage;
void _takePicture() async {
final imagePicker = ImagePicker();
//
Kamerayı tetikler ve çekilen resmi yakalar (Galeri için Source: gallery seçilebilir)
final pickedImage = await imagePicker.pickImage(
source:
ImageSource.camera,
maxWidth:
600, //
Performans için resmi boyutlandırıyoruz
);
if (pickedImage
== null) return;
setState(() {
_selectedImage
= File(pickedImage.path); // String yolu gerçek dosyaya
dönüştürülüyor
});
widget.onPickImage(_selectedImage!); //
Seçilen resmi üst forma fırlatıyoruz
}
@override
Widget build(BuildContext context) {
Widget content = TextButton.icon(
icon:
const Icon(Icons.camera),
label:
const Text('Fotoğraf Çek'),
onPressed:
_takePicture,
);
if (_selectedImage
!= null) {
content
= GestureDetector(
onTap:
_takePicture, //
Resme tekrar tıklanırsa kamerayı yeniden açar
child:
Image.file(
_selectedImage!,
fit: BoxFit.cover,
width:
double.infinity,
height:
double.infinity,
),
);
}
return Container(
decoration:
BoxDecoration(
border:
Border.all(width: 1,
color: Theme.of(context).colorScheme.primary.withAlpha(100)),
),
height:
250,
width:
double.infinity,
alignment:
Alignment.center,
child:
content,
);
}
}
3.
lib/widgets/location_input.dart (GPS Konum Yakalama Bileşeni)
Cihazın
GPS donanımına erişerek anlık enlem ve boylam bilgilerini çeken arayüz
elemanıdır.
import 'package:flutter/material.dart';
import 'package:location/location.dart';
import 'package:favori_mekanlar_app/models/place.dart';
class LocationInput extends StatefulWidget
{
const LocationInput({super.key, required this.onPickLocation});
final void Function(PlaceLocation location)
onPickLocation;
@override
State<LocationInput> createState() => _LocationInputState();
}
class _LocationInputState extends State<LocationInput> {
PlaceLocation? _pickedLocation;
var
_isGettingLocation = false;
void _getCurrentLocation() async {
Location location = Location();
bool
serviceEnabled;
PermissionStatus permissionGranted;
LocationData locationData;
//
1. Telefonun konum servisi açık mı kontrolü
serviceEnabled
= await location.serviceEnabled();
if (!serviceEnabled)
{
serviceEnabled
= await location.requestService();
if (!serviceEnabled)
return;
}
//
2. Uygulamaya konum izni verilmiş mi kontrolü
permissionGranted
= await location.hasPermission();
if (permissionGranted
== PermissionStatus.denied) {
permissionGranted
= await location.requestPermission();
if (permissionGranted
!= PermissionStatus.granted) return;
}
setState(() {
_isGettingLocation
= true;
});
//
3. Donanımdan canlı GPS koordinatlarını alıyoruz
locationData
= await location.getLocation();
if (locationData.latitude
== null || locationData.longitude
== null) return;
//
Google Maps API anahtarınız varsa koordinatları
adrese çevirebilirsiniz (Geocoding).
//
Test amaçlı şimdilik statik bir adres metni basıyoruz.
final coordinates = PlaceLocation(
latitude:
locationData.latitude!,
longitude:
locationData.longitude!,
address:
'Enlem: ${locationData.latitude}, Boylam: ${locationData.longitude}',
);
setState(() {
_pickedLocation
= coordinates;
_isGettingLocation
= false;
});
widget.onPickLocation(_pickedLocation!);
}
@override
Widget build(BuildContext context) {
Widget previewContent = const Text('Konum seçilmedi', textAlign: TextAlign.center);
if (_isGettingLocation)
{
previewContent
= const CircularProgressIndicator();
}
if (_pickedLocation
!= null) {
previewContent
= Text(
_pickedLocation!.address,
textAlign:
TextAlign.center,
style:
Theme.of(context).textTheme.bodyLarge,
);
}
return Column(
children:
[
Container(
height:
120,
width:
double.infinity,
alignment:
Alignment.center,
decoration:
BoxDecoration(
border: Border.all(width: 1, color: Theme.of(context).colorScheme.primary.withAlpha(100)),
),
child:
previewContent,
),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children:
[
TextButton.icon(
icon: const Icon(Icons.location_on),
label: const Text('Anlık
Konumu Al'),
onPressed: _getCurrentLocation,
),
],
)
],
);
}
}
4.
lib/providers/user_places.provider.dart (SQLite
Yerel Veri Tabanı Motoru)
Uygulama
kapansa dahi verilerin telefonda kalması için SQLite veritabanını (sqflite) başlatan, kayıt yapan (INSERT) ve verileri çeken (SELECT) Riverpod
merkezidir.
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart' as syspaths;
import 'package:path/path.dart' as path;
import 'package:sqflite/sqflite.dart' as sql;
import 'package:sqflite/sqlite_api.dart';
import 'package:favori_mekanlar_app/models/place.dart';
// Veri tabanına
bağlantı açan yardımcı global fonksiyon
Future<Database> _getDatabase() async {
final dbPath = await sql.getDatabasesPath();
//
'places.db' adında yerel bir SQLITE dosyası oluşturur
veya mevcut olanı açar.
final db = await sql.openDatabase(
path.join(dbPath, 'places.db'),
onCreate:
(db, version) {
//
Tablo şemamızı oluşturuyoruz. Resim dosyasının yolunu TEXT olarak saklayacağız.
return db.execute(
'CREATE
TABLE user_places(id TEXT PRIMARY KEY, title TEXT, image TEXT, lat REAL, lng REAL, address TEXT)',
);
},
version:
1,
);
return db;
}
class UserPlacesNotifier extends StateNotifier<List<Place>> {
UserPlacesNotifier() : super([]);
//
Cihaz açıldığında SQL tablosundaki tüm mekanları hafızaya yükleyen metot
Future<void> loadPlaces() async {
final db = await _getDatabase();
//
'user_places' tablosundaki tüm satırları çeker
final data = await db.query('user_places');
//
Gelen ham SQL satırlarını tek tek map'leyerek güçlü
nesne listemize çeviriyoruz
final places = data.map((row) => Place(
id: row['id'] as String,
title:
row['title'] as String,
image:
File(row['image'] as String), // Disk yolundan dosyayı okur
location:
PlaceLocation(
latitude: row['lat'] as double,
longitude: row['lng'] as double,
address: row['address'] as String,
),
)).toList();
state
= places;
}
//
Yeni mekanı hem SQLite veri
tabanına yazan hem de ekrana yansıtan fonksiyon
void addPlace(String title, File image, PlaceLocation location) async {
//
Performans için çekilen resmi uygulamanın kalıcı gizli klasörüne kopyalıyoruz
final appDir = await syspaths.getApplicationDocumentsDirectory();
final filename = path.basename(image.path);
final copiedImage = await image.copy('${appDir.path}/$filename');
final newPlace = Place(title: title, image:
copiedImage, location: location);
final db = await _getDatabase();
//
SQL INSERT sorgusu
await db.insert('user_places', {
'id': newPlace.id,
'title': newPlace.title,
'image': newPlace.image.path,
// Resmin disk üzerindeki string
yolunu kaydeder
'lat':
newPlace.location.latitude,
'lng':
newPlace.location.longitude,
'address': newPlace.location.address,
});
state
= [newPlace, ...state];
}
}
final userPlacesProvider
= StateNotifierProvider<UserPlacesNotifier,
List<Place>>((ref) {
return UserPlacesNotifier();
});
5.
lib/screens/add_place_screen.dart (Mekan Kayıt Form Ekranı)
Kullanıcının
başlık yazdığı, fotoğraf çektiği ve konum aldığı entegre form sayfasıdır.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:favori_mekanlar_app/models/place.dart';
import 'package:favori_mekanlar_app/providers/user_places.provider.dart';
import 'package:favori_mekanlar_app/widgets/image_input.dart';
import 'package:favori_mekanlar_app/widgets/location_input.dart';
class AddPlaceScreen extends ConsumerStatefulWidget
{
const AddPlaceScreen({super.key});
@override
ConsumerState<AddPlaceScreen> createState() => _AddPlaceScreenState();
}
class _AddPlaceScreenState extends ConsumerState<AddPlaceScreen> {
final _titleController = TextEditingController();
File? _takenImage;
PlaceLocation? _selectedLocation;
void _savePlace() {
final enteredTitle = _titleController.text;
if (enteredTitle.isEmpty
|| _takenImage == null || _selectedLocation == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Lütfen başlık, fotoğraf ve konum alanlarını doldurun!')),
);
return;
}
//
Riverpod deposu üzerinden veriyi SQLite
tablosuna yazdırıyoruz
ref.read(userPlacesProvider.notifier).addPlace(enteredTitle, _takenImage!, _selectedLocation!);
Navigator.of(context).pop(); //
Listeye geri dön
}
@override
void dispose() {
_titleController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar:
AppBar(title: const Text('Yeni Mekan Ekle')),
body: SingleChildScrollView(
padding:
const EdgeInsets.all(12),
child:
Column(
children:
[
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText:
'Mekan Adı'),
style: TextStyle(color: Theme.of(context).colorScheme.onSurface),
),
const SizedBox(height: 16),
ImageInput(onPickImage: (image) => _takenImage = image),
const SizedBox(height: 16),
LocationInput(onPickLocation: (loc) => _selectedLocation = loc),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _savePlace,
icon: const Icon(Icons.add),
label: const Text('Mekanı
Kaydet'),
),
],
),
),
);
}
}
6.
lib/screens/places_screen.dart (Ana Liste Ekranı)
Açılışta
SQL tablosundaki verileri çeken (FutureBuilder) ve mekanları listeleyen ana
giriş sayfasıdır.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:favori_mekanlar_app/providers/user_places.provider.dart';
import 'package:favori_mekanlar_app/screens/add_place_screen.dart';
import 'package:favori_mekanlar_app/screens/place_detail_screen.dart';
class PlacesScreen extends ConsumerWidget
{
const PlacesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final userPlaces = ref.watch(userPlacesProvider);
return Scaffold(
appBar:
AppBar(
title:
const Text('Favori Mekanlarım'),
actions:
[
IconButton(
icon: const Icon(Icons.add),
onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (ctx) => const AddPlaceScreen())),
)
],
),
//
FutureBuilder: Sayfa ilk açıldığında arka planda SQLite yüklemesini bekler.
body: FutureBuilder(
future:
ref.read(userPlacesProvider.notifier).loadPlaces(),
builder:
(context, snapshot) => snapshot.connectionState == ConnectionState.waiting
? const Center(child: CircularProgressIndicator())
: userPlaces.isEmpty
? const Center(child: Text('Henüz hiç mekan
eklemediniz.'))
: ListView.builder(
itemCount: userPlaces.length,
itemBuilder: (ctx, index) => ListTile(
// Çekilen yerel fotoğrafı yuvarlak avatar olarak önizler
leading: CircleAvatar(
radius: 26,
backgroundImage: FileImage(userPlaces[index].image),
),
title: Text(userPlaces[index].title),
subtitle: Text(userPlaces[index].location.address),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (ctx) => PlaceDetailScreen(place: userPlaces[index])),
);
},
),
),
),
);
}
}
7.
lib/screens/place_detail_screen.dart (Mekan
Detay Ekranı)
Fotoğrafın
tam ekran gösterildiği ve harita koordinatlarının sergilendiği detay
sayfasıdır.
import 'package:flutter/material.dart';
import 'package:favori_mekanlar_app/models/place.dart';
class PlaceDetailScreen extends StatelessWidget
{
const PlaceDetailScreen({super.key, required this.place});
final Place place;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar:
AppBar(title: Text(place.title)),
body: Stack(
children:
[
Image.file(place.image, fit: BoxFit.cover, width: double.infinity, height: double.infinity),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
color: Colors.black54,
padding: const EdgeInsets.symmetric(vertical:
16, horizontal: 24),
child: Text(
place.location.address,
textAlign: TextAlign.center,
style: const TextStyle(color:
Colors.white, fontSize: 16),
),
),
)
],
),
);
}
}
8.
lib/main.dart (Giriş Kapısı ve Riverpod Scope)
Uygulamayı
başlatan ana yürütücü dosya.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:favori_mekanlar_app/screens/places_screen.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends StatelessWidget
{
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner:
false,
title:
'Yerel Mekan Hafızası',
theme:
ThemeData.dark().copyWith(useMaterial3:
true),
home:
const PlacesScreen(),
);
}
}
Harika!
Donanım özelliklerini ve yerel SQL veri tabanını başarıyla geride bıraktık.
Şimdi sıra, modern mobil uygulama geliştirmenin zirve noktası olan Proje 7:
Canlı Sohbet Uygulaması (Chat App with
Firebase) projesinde!
Bu
final projesinde; kullanıcı kayıt ve giriş işlemlerini güvenli hale
getireceğimiz Firebase Authentication
altyapısını, mesajları milisaniyeler içinde tüm cihazlara eşzamanlı aktaran Cloud
Firestore Streams
yapısını ve uygulama kapalıyken bile telefona bildirim düşüren Firebase Cloud Messaging (Push
Notifications) mekanizmasını kuracağız.
💬 Proje 7:
Canlı Sohbet Uygulaması (Chat App with
Firebase)
📁 Adım 1:
Proje Dosya Yapısı
Gerçek
zamanlı bir sistem kuracağımız için arayüz ve form elemanlarını ayırarak temiz
bir hiyerarşi oluşturuyoruz:
canli_sohbet_firebase_app/
├──
lib/
│ ├── screens/
│ │
├── auth_screen.dart
│ │
├── chat_screen.dart
│ │
└── splash_screen.dart
│ ├── widgets/
│ │
├── chat_messages.dart
│ │
└── new_message.dart
│ └── main.dart
└──
pubspec.yaml
⚙️ Adım 2:
Gerekli Paketlerin Kurulması (pubspec.yaml)
Firebase entegrasyonu ve bildirim yönetimi için resmi ve güncel FlutterFire paketlerini projemize ekliyoruz:
dependencies:
flutter:
sdk: flutter
firebase_core: ^3.0.0
firebase_auth: ^5.0.0
cloud_firestore: ^5.0.0
firebase_messaging: ^15.0.0
Not:
Paketleri kaydettikten sonra terminalde flutter pub
get komutunu çalıştırın. Canlı bir altyapı için Firebase konsolunda proje oluşturup flutterfire configure komutuyla platform
dosyalarını (google-services.json vb.) projenize bağlamanız
gerekmektedir.
💻 Adım 3:
Adım Adım Kodlama
1.
lib/screens/splash_screen.dart (Yüklenme / Karşılama Ekranı)
Firebase oturum durumunu arka planda sorgularken kullanıcının donma
hissetmemesi için gösterilen geçici ara ekrandır.
import 'package:flutter/material.dart';
class SplashScreen extends StatelessWidget
{
const SplashScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar:
AppBar(title: const Text('Sohbet Yükleniyor...')),
body: const Center(
child:
Column(
mainAxisAlignment:
MainAxisAlignment.center,
children:
[
CircularProgressIndicator(),
SizedBox(height:
16),
Text('Oturum kontrol ediliyor, lütfen bekleyin...'),
],
),
),
);
}
}
2.
lib/screens/auth_screen.dart (Kayıt ve Giriş Ekranı - Firebase Auth)
Kullanıcının
e-posta ve şifreyle güvenli şekilde kayıt olmasını (createUserWithEmailAndPassword) veya giriş yapmasını (signInWithEmailAndPassword) sağlayan form katmanıdır.
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
// Firebase
Auth nesnesini başlatıyoruz
final _firebase = FirebaseAuth.instance;
class AuthScreen extends StatefulWidget
{
const AuthScreen({super.key});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final _formKey = GlobalKey<FormState>();
var
_isLogin = true; // Giriş modunda mı
yoksa Kayıt modunda mı kontrolü
var
_enteredEmail = '';
var
_enteredPassword = '';
var
_isAuthenticating = false;
void _submit() async {
final isValid = _formKey.currentState!.validate();
if (!isValid) return;
_formKey.currentState!.save();
try {
setState(() {
_isAuthenticating
= true;
});
if (_isLogin) {
//
Firebase Giriş Fonksiyonu
await _firebase.signInWithEmailAndPassword(
email:
_enteredEmail,
password:
_enteredPassword,
);
} else {
//
Firebase Kayıt Fonksiyonu
await _firebase.createUserWithEmailAndPassword(
email:
_enteredEmail,
password:
_enteredPassword,
);
}
} on FirebaseAuthException
catch (error) {
//
Firebase'den gelen özel şifre/eposta hatalarını
yakalayıp kullanıcıya gösteriyoruz
if (!mounted) return;
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.message ?? 'Kimlik
doğrulama başarısız oldu.')),
);
setState(() {
_isAuthenticating
= false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
body: Center(
child:
SingleChildScrollView(
child:
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.forum_outlined, size: 100,
color: Colors.indigo),
const SizedBox(height: 20),
Card(
margin: const EdgeInsets.all(20),
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key:
_formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
decoration: const InputDecoration(labelText:
'E-posta Adresi'),
keyboardType: TextInputType.emailAddress,
autocorrect: false,
textCapitalization: TextCapitalization.none,
validator: (value) {
if (value == null || value.trim().isEmpty || !value.contains('@'))
{
return 'Lütfen
geçerli bir e-posta adresi giriniz.';
}
return null;
},
onSaved: (value) => _enteredEmail = value!,
),
TextFormField(
decoration: const InputDecoration(labelText:
'Şifre'),
obscureText: true, // Şifreyi gizler
(****)
validator: (value) {
if (value == null || value.trim().length < 6) {
return 'Şifre
en az 6 karakter uzunluğunda olmalıdır.';
}
return null;
},
onSaved: (value) => _enteredPassword = value!,
),
const SizedBox(height: 12),
if (_isAuthenticating)
const CircularProgressIndicator(),
if (!_isAuthenticating)
ElevatedButton(
onPressed: _submit,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
),
child: Text(_isLogin ? 'Giriş
Yap' : 'Kayıt
Ol'),
),
if (!_isAuthenticating)
TextButton(
onPressed: () {
setState(()
{
_isLogin = !_isLogin;
// Modlar arası geçiş yapar
});
},
child: Text(_isLogin ? 'Yeni
bir hesap oluştur' : 'Zaten bir hesabım
var'),
),
],
),
),
),
),
],
),
),
),
);
}
}
3.
lib/widgets/new_message.dart (Firestore - Canlı Mesaj Gönderme Formu)
Kullanıcının
klavyeden yazdığı metni buluttaki NoSQL veri tabanına
(Firestore) anlık zaman damgasıyla (Timestamp.now()) kayıt eden alt girdi
alanıdır.
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
class NewMessage extends StatefulWidget
{
const NewMessage({super.key});
@override
State<NewMessage> createState() => _NewMessageState();
}
class _NewMessageState extends State<NewMessage> {
final _messageController = TextEditingController();
void _sendMessage() async {
final enteredMessage = _messageController.text;
if (enteredMessage.trim().isEmpty) return;
//
Klavyeyi ve input alanını hemen temizliyoruz
FocusScope.of(context).unfocus();
_messageController.clear();
//
Aktif kullanıcının bilgilerini alıyoruz
final user = FirebaseAuth.instance.currentUser!;