📖 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:

  1. Widget Tree (Widget Ağacı): Sizin yazdığınız, arayüzün konfigurasyonunu tutan hafif ve sürekli yok edilip yeniden oluşturulabilen ağaçtır.
  2. Element Tree (Eleman Ağacı): Widget'lar ile ekrana çizilen gerçek nesneler arasındaki köprüdür. Değişmeyen iskeleti tutar, yaşam döngüsünü yönetir ve performansı koordine eder.
  3. Render Tree (Çizim Ağacı): Ekranda piksellerin nereye basılacağını, boyutlarını ve koordinatlarını hesaplayan ağır matematiksel katmandır.

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.

  1. Firebase Console üzerinden bir proje açın ve "Realtime Database" oluşturun.
  2. Kurallar (Rules) sekmesinden okuma ve yazma izinlerini test amaçlı true yapın: ".read": true, ".write": true.
  3. Veri tabanınızın size verdiği https://firebaseio.com şeklindeki URL adresini kopyalayın (Aşağıdaki kodlarda bu URL kullanılacaktır).

💻 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!;