Jak zacząć z Flutterem: pierwsza aplikacja mobilna krok po kroku

0
13
Rate this post

Nawigacja:

Decyzja startowa: czy Flutter to właściwa droga do pierwszej aplikacji mobilnej

Kryteria wyboru na zimno

Przed instalacją czegokolwiek odpowiedz na kilka konkretnych pytań. To zawęzi zakres i pozwoli oszczędzić godziny:

  • Czy chcesz jednocześnie celować w Android i iOS bez duplikowania pracy? Jeśli tak, Flutter zwykle daje najszybszy efekt „jednego kodu na dwa systemy”.
  • Czy interfejs ma być spójny i bardzo płynny nawet na słabszych urządzeniach? Silnik renderujący Fluttera działa niezależnie od natywnych widoków, co co do zasady przekłada się na przewidywalność UI.
  • Czy dopuszczasz naukę nowego języka (Dart) w zamian za prosty model UI oparty o widgety? Jeśli tak, wejście jest relatywnie łagodne.
  • Czy planujesz używać natywnych API (np. Apple HealthKit) już w pierwszym wydaniu? Da się, ale wymaga mostków (platform channels) lub gotowych pluginów.

Jeśli na co najmniej dwa pierwsze pytania odpowiedź brzmi „tak”, Flutter jest rozsądnym wyborem do pierwszej aplikacji mobilnej krok po kroku. Gdy kluczowa jest tylko jedna platforma, a zespół zna ją świetnie, rozważ też natywny start; dla jednej platformy czasami będzie to krótsza ścieżka.

Zakres pierwszej aplikacji, który realnie dowieziesz

Na początek zdefiniuj najprostszy produkt, który pokaże pełny „przekrój” prac:

  • 1–2 ekrany,
  • lista zadań lub notatek (CRUD),
  • trwałość danych lokalnie (np. shared_preferences),
  • lekka nawigacja (push/pop),
  • tematyka Material 3,
  • ikona aplikacji i poprawna nazwa pakietu.

Taki cel wymusza dotknięcie najważniejszych obszarów: UI, stan, dane, konfiguracja, uruchomienie na urządzeniu, a nawet przygotowanie do wydania.

Mini-brief pytań, na które szukasz odpowiedzi

  • Jak zainstalować Fluttera i przejść przez flutter doctor bez ostrzeżeń?
  • Jak utworzyć projekt, zrozumieć strukturę folderów i uruchomić hot reload?
  • Jak napisać prosty ekran listy z formularzem dodawania i trwałością danych?
  • Jak dobrać i dodać zależności w pubspec.yaml bez konfliktów?
  • Jak przetestować podstawową logikę i UI?
  • Jak przygotować release na Android i iOS (podpisywanie, identyfikatory, wersjonowanie)?

Checklista przygotowań: środowisko i narzędzia

Wymagania wstępne i decyzje narzędziowe

Na starcie przyjmij prosty układ narzędziowy:

  • System: Windows 10/11, macOS (dla iOS wymagany macOS z Xcode), Linux (Ubuntu/Fedora itp.).
  • IDE: Android Studio lub Visual Studio Code (z wtyczkami Dart i Flutter).
  • Android SDK i emulator Pixel (AVD). Dla iOS – Xcode, iOS Simulator, CocoaPods.
  • Git dla kontroli wersji.
  • Java (JDK 17) dla współczesnych wersji Gradle/AGP.

Jeśli masz tylko Windows lub Linux, skup się na Androidzie. Na iOS zbudujesz dopiero na macOS.

Instalacja Flutter SDK krok po kroku

  1. Pobierz archiwum Flutter SDK z oficjalnej strony (kanał stable).
  2. Rozpakuj do stałej lokalizacji (np. C:/src/flutter lub ~/development/flutter).
  3. Dodaj do PATH folder flutter/bin. Zmianę zastosuj w terminalu/PowerShellu (sprawdź poleceniem flutter –version).
  4. Uruchom flutter doctor i zanotuj braki.

Jeżeli korzystasz z VS Code, doinstaluj rozszerzenia „Flutter” i „Dart”. W Android Studio uruchom kreator SDK Manager i pobierz przynajmniej najnowszą stabilną platformę Android, narzędzia build-tools oraz emulator.

Jak zacząć z Flutterem: pierwsza aplikacja mobilna krok po kroku
Źródło: Pexels | Autor: Shoper .pl

Android Studio, SDK i emulator – konfiguracja bez niespodzianek

  1. Zainstaluj Android Studio i podczas pierwszego uruchomienia wybierz SDK Location bez spacji w ścieżce, jeśli to możliwe.
  2. SDK Manager: pobierz „Android SDK Platform”, „Android SDK Build-Tools” i najnowszy „Android Emulator”.
  3. AVD Manager: utwórz wirtualne urządzenie, np. Pixel 5, system image x86_64 (lub ARM na Apple Silicon), API 33–35.
  4. W Windows upewnij się, że masz włączoną wirtualizację (BIOS/UEFI) i Hyper-V/WSL nie koliduje z HAXM/WHX.

Xcode, CocoaPods i iOS Simulator (macOS)

  • App Store: zainstaluj Xcode. Otwórz raz i zaakceptuj licencję, doinstaluj komponenty.
  • Zainstaluj CocoaPods: gem install cocoapods lub przez Homebrew: brew install cocoapods.
  • Uruchom iOS Simulator z Xcode (Devices and Simulators) i pobierz aktualne obrazy systemów.

flutter doctor – interpretacja i typowe naprawy

Polecenie podstawowe:

flutter doctor -v
  • Android toolchain: jeśli JDK jest niekompatybilne, zainstaluj JDK 17 i ustaw JAVA_HOME.
  • Android licenses: zaakceptuj licencje: flutter doctor –android-licenses.
  • Xcode: brak CocoaPods? Zainstaluj i podaj PATH (sprawdź pod: which pod).
  • Connected device: uruchom emulator (Android) lub iOS Simulator albo podłącz telefon z włączonym debugowaniem.

Pierwsze uruchomienie projektu i anatomia katalogów

Tworzenie projektu poleceniami CLI

flutter create pierwsza_aplikacja
cd pierwsza_aplikacja
flutter run

Jeżeli masz uruchomiony emulator/urządzenie, pojawi się przykładowa aplikacja „Counter”. To szybki test hot reloadingu i pipeline’u build.

Struktura plików, które zobaczysz

  • lib/: kod aplikacji w Dart (start: main.dart).
  • test/: testy jednostkowe i widgetowe.
  • android/ i ios/: projekty natywne (Gradle, Xcode), konfiguracje wydaniowe.
  • pubspec.yaml: deklaracja zależności, assetów, nazw i wersji.

W codziennej pracy edytujesz głównie lib/ i pubspec.yaml; katalogi platformowe dotykasz przy wydaniu lub dodawaniu natywnych konfiguracji (uprawnienia, ikony, signing).

Hot reload kontra hot restart

  • Hot reload (r): wstrzykuje zmiany w kodzie i odświeża UI bez utraty stanu (gdy to możliwe). Idealny do zmian layoutu i logiki budowania widgetów.
  • Hot restart (R): restartuje aplikację i inicjuje ją od zera. Potrzebny po zmianach globalnych lub inicjalizacji.

Minimalna modyfikacja dla polskiego kontekstu

Ustaw język polski i aktywuj Material 3:

Aktywuj Material 3 i polską lokalizację. Najprościej dodać lokalizacje jako zależność SDK i ustawić motyw oraz język w MaterialApp.

# pubspec.yaml (fragment)
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter

Następnie zaktualizuj punkt wejścia:

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Lista zadań',
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: Colors.teal, // jeden kolor wejściowy do całej palety
      ),
      locale: const Locale('pl'),
      supportedLocales: const [Locale('pl'), Locale('en')],
      localizationsDelegates: const [
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      home: const Placeholder(), // za chwilę podmienisz na ekran listy
    );
  }
}

Uwaga: jeśli korzystasz z niestandardowych fontów lub ikon, dodaj je do pubspec.yaml i wykonaj flutter pub get przed uruchomieniem.

Budowa rdzenia: lista zadań (CRUD) + trwałość danych

Jak zacząć z Flutterem: pierwsza aplikacja mobilna krok po kroku
Źródło: Pexels | Autor: AS Photography

Dodanie zależności i wybór prostego magazynu

Na start wystarczy pamięć lokalna. Plugin shared_preferences przechowa listę w formacie JSON. Dodaj zależność poleceniem:

flutter pub add shared_preferences

Po instalacji wykonaj krótką próbę kompilacji, aby złapać ewentualne konflikty wersji:

flutter pub get
flutter analyze

Ekran główny: lista, dodawanie, usuwanie

Utwórz prosty ekran z polem tekstowym, przyciskiem dodawania i listą, która wspiera usuwanie gestem. Dane trzymaj w pamięci i zapisuj po każdej zmianie do shared_preferences.

// lib/main.dart (podmień Placeholder na HomePage)
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Lista zadań',
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: Colors.teal,
      ),
      locale: const Locale('pl'),
      supportedLocales: const [Locale('pl'), Locale('en')],
      localizationsDelegates: const [
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final _ctrl = TextEditingController();
  final _formKey = GlobalKey<FormState>();
  List<String> _tasks = [];
  bool _loading = true;

  @override
  void initState() {
    super.initState();
    _loadTasks();
  }

  Future<void> _loadTasks() async {
    final prefs = await SharedPreferences.getInstance();
    final raw = prefs.getString('tasks');
    setState(() {
      _tasks = raw == null ? [] : List<String>.from(jsonDecode(raw));
      _loading = false;
    });
  }

  Future<void> _saveTasks() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('tasks', jsonEncode(_tasks));
  }

  Future<void> _addTask(String text) async {
    setState(() => _tasks.insert(0, text.trim()));
    await _saveTasks();
    _ctrl.clear();
  }

  Future<void> _removeTask(int index) async {
    setState(() => _tasks.removeAt(index));
    await _saveTasks();
  }

  @override
  void dispose() {
    _ctrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Moje zadania')),
      body: _loading
          ? const Center(child: CircularProgressIndicator())
          : Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                children: [
                  Form(
                    key: _formKey,
                    child: Row(
                      children: [
                        Expanded(
                          child: TextFormField(
                            controller: _ctrl,
                            decoration: const InputDecoration(
                              labelText: 'Nowe zadanie',
                              border: OutlineInputBorder(),
                            ),
                            validator: (v) {
                              if (v == null || v.trim().isEmpty) {
                                return 'Wpisz treść zadania';
                              }
                              if (v.trim().length < 3) {
                                return 'Minimum 3 znaki';
                              }
                              return null;
                            },
                            onFieldSubmitted: (_) {
                              if (_formKey.currentState!.validate()) {
                                _addTask(_ctrl.text);
                              }
                            },
                          ),
                        ),
                        const SizedBox(width: 8),
                        FilledButton.icon(
                          icon: const Icon(Icons.add),
                          label: const Text('Dodaj'),
                          onPressed: () {
                            if (_formKey.currentState!.validate()) {
                              _addTask(_ctrl.text);
                            }
                          },
                        ),
                      ],
                    ),
                  ),
                  const SizedBox(height: 16),
                  Expanded(
                    child: _tasks.isEmpty
                        ? const Center(child: Text('Brak zadań. Dodaj pierwsze.'))
                        : ListView.separated(
                            itemCount: _tasks.length,
                            separatorBuilder: (_, __) => const Divider(height: 1),
                            itemBuilder: (context, index) {
                              final item = _tasks[index];
                              return Dismissible(
                                key: ValueKey(item + index.toString()),
                                direction: DismissDirection.endToStart,
                                background: Container(
                                  color: Theme.of(context).colorScheme.errorContainer,
                                  alignment: Alignment.centerRight,
                                  padding: const EdgeInsets.symmetric(horizontal: 16),
                                  child: Icon(Icons.delete, color: Theme.of(context).colorScheme.onErrorContainer),
                                ),
                                onDismissed: (_) => _removeTask(index),
                                child: ListTile(
                                  title: Text(item),
                                  trailing: IconButton(
                                    icon: const Icon(Icons.delete_outline),
                                    onPressed: () => _removeTask(index),
                                    tooltip: 'Usuń',
                                  ),
                                ),
                              );
                            },
                          ),
                  ),
                ],
              ),
            ),
    );
  }
}

Kryteria sprawdzenia po uruchomieniu:

  • Dodanie zadania działa enterem i przyciskiem „Dodaj”.
  • Po restarcie aplikacji lista nie jest pusta (dane zapisują się lokalnie).
  • Przesunięcie elementu w lewo usuwa go bez błędów.

Ostrzeżenie: shared_preferences nie jest bazą danych. Gdy lista zacznie rosnąć, rozważ drift/sqflite lub isar. Na start wystarczy proste klucze–wartości.

Podstawowa nawigacja i drugi ekran

Dodanie ekranu „Szczegóły” z minimalnym edytorem

Drugi ekran pozwala dotknąć nawigacji i prostych formularzy. Przekaż wybrany tekst, umożliw edycję i zwróć wynik.

// lib/detail_page.dart
import 'package:flutter/material.dart';

class DetailPage extends StatefulWidget {
  final String initial;
  const DetailPage({super.key, required this.initial});

  @override
  State<DetailPage> createState() => _DetailPageState();
}

class _DetailPageState extends State<DetailPage> {
  late final TextEditingController _c;

  @override
  void initState() {
    super.initState();
    _c = TextEditingController(text: widget.initial);
  }

  @override
  void dispose() {
    _c.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Szczegóły')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              controller: _c,
              decoration: const InputDecoration(
                labelText: 'Treść zadania',
                border: OutlineInputBorder(),
              ),
              minLines: 1,
              maxLines: 3,
            ),
            const SizedBox(height: 12),
            FilledButton(
              onPressed: () => Navigator.of(context).pop(_c.text.trim()),
              child: const Text('Zapisz'),
            ),
          ],
        ),
      ),
    );
  }
}

Podłącz nawigację z listy (krótki tap otwiera ekran edycji, wynik nadpisuje zadanie i zapisuje):

// lib/home_page.dart (fragment)

Podpięcie nawigacji z listy (tap = edycja, wynik = zapis)

Zmieniasz element listy tak, aby dotknięcie otwierało edytor i po powrocie aktualizowało wpis. Dodaj import i obsługę onTap:

// lib/home_page.dart (nagłówki)
import 'detail_page.dart'; // dodaj import

// ...w build(), w miejscu ListTile:
child: ListTile(
  title: Text(item),
  onTap: () async {
    final updated = await Navigator.of(context).push<String>(
      MaterialPageRoute(
        builder: (_) => DetailPage(initial: item),
        fullscreenDialog: true,
      ),
    );
    if (!mounted) return;
    if (updated == null) return;           // anulowano
    final t = updated.trim();
    if (t.isEmpty || t == item) return;    // bez zmian lub pusty
    setState(() => _tasks[index] = t);
    await _saveTasks();
  },
  trailing: IconButton(
    icon: const Icon(Icons.edit_outlined),
    tooltip: 'Edytuj',
    onPressed: () async {
      final updated = await Navigator.of(context).push<String>(
        MaterialPageRoute(builder: (_) => DetailPage(initial: item)),
      );
      if (!mounted) return;
      if (updated == null) return;
      final t = updated.trim();
      if (t.isEmpty || t == item) return;
      setState(() => _tasks[index] = t);
      await _saveTasks();
    },
  ),
),

Bezpieczne usuwanie: przesunięcie + cofnięcie (SnackBar)

Gest przesunięcia usuwa element. Dodaj krótkie okno cofnięcia. W praktyce daje to margines błędu – użytkownik może wyjść z kłopotów jednym tapnięciem.

// lib/home_page.dart (fragment - w Dismissible)
onDismissed: (_) async {
  final removed = item;     // zapamiętaj
  final removedIndex = index;
  await _removeTask(index); // usuń i zapisz

  if (!mounted) return;
  ScaffoldMessenger.of(context).clearSnackBars();
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text('Usunięto: $removed'),
      duration: const Duration(seconds: 3),
      action: SnackBarAction(
        label: 'Cofnij',
        onPressed: () async {
          // wstaw z powrotem pod dawny indeks lub na koniec
          final insertAt = removedIndex <= _tasks.length ? removedIndex : _tasks.length;
          setState(() => _tasks.insert(insertAt, removed));
          await _saveTasks();
        },
      ),
    ),
  );
},

Kryteria sprawdzenia po podpięciu edycji

  • Tap na elemencie otwiera ekran „Szczegóły”, powrót bez zapisu nie zmienia listy.
  • Zapis zmienionego tekstu nadpisuje istniejące zadanie i pozostaje po restarcie.
  • Przesunięcie w lewo usuwa i pokazuje SnackBar; „Cofnij” przywraca wpis bez błędów.

Ostrzeżenia i typowe niuanse

  • Po await Navigator.push(...) użyj if (!mounted) return;, aby uniknąć setState na odmontowanym widżecie.
  • Nie zapisuj pustych wyników edycji – odrzuć null i białe znaki (trim().isEmpty).
  • Gdy elementy są identyczne, użyj w Dismissible klucza łączącego treść i indeks (co już masz), aby uniknąć konfliktów.

Uruchomienie na urządzeniu i szybkie buildy

Procedura: emulator lub fizyczny telefon

  1. Włącz emulator (Android Studio/AVD) lub podłącz urządzenie przez USB i włącz debugowanie (Android) / zaufaj komputerowi (iOS).
  2. Sprawdź listę urządzeń: flutter devices. Jeśli pusto – doinstaluj SDK/emulator i zaakceptuj licencje: flutter doctor --android-licenses.
  3. Uruchom aplikację: flutter run -d <ID_urządzenia>. Podczas działania:
    • r – Hot Reload (zmiany w kodzie UI bez restartu stanu),
    • R – Hot Restart (szybki restart, czyści stan),
    • q – wyjście.

Pakiet instalacyjny do szybkiej weryfikacji

  • Android (debug APK): flutter build apk --debug i zainstaluj na urządzeniu.
  • Android (release AAB/APK): flutter build appbundle lub flutter build apk --release – do publikacji wymagane jest podpisanie.
  • iOS: flutter build ios (wymaga Xcode; instalacja na urządzeniu zwykle przez Xcode z właściwym profilem).

Lista kontrolna przed buildem

  • Tryb produkcyjny wyłączony banner debug: masz debugShowCheckedModeBanner: false.
  • Ikona i nazwę aplikacji ustawisz później; na start nie blokuje to builda.
  • Jeśli Gradle pobiera się długo przy pierwszym uruchomieniu – to normalne; kolejny build będzie krótszy.

Najrozsądniej wdrażać małe kroki: jedna zmiana w UI, szybki Hot Reload, potem zapis stanu i krótki test cofnięcia. Dzięki temu błąd widać od razu, a poprawka zajmuje minuty, nie godzinę.

Uporządkowanie stanu: lekki store z ChangeNotifier

Cel i decyzja: kiedy lokalny setState, a kiedy wspólny stan

Jeżeli stan dotyczy jednego ekranu – setState zwykle wystarczy. Gdy zaczynasz współdzielić dane (lista zadań + ustawienia + drugi ekran), przejście do prostego store’u upraszcza zależności i testy.

Jak zacząć z Flutterem: pierwsza aplikacja mobilna krok po kroku
Źródło: Pexels | Autor: Nikolai Kolosov

Procedura refaktoryzacji do Provider + ChangeNotifier

  1. Dodaj zależność: provider (pubspec.yaml – sekcja dependencies) i zaktualizuj pakiety.
  2. Utwórz TaskStore odpowiedzialny za listę i zapis do shared_preferences.
  3. Uruchamiaj aplikację dopiero po zainicjalizowaniu store’u (FutureBuilder w bootstrapie).
  4. W HomePage używaj context.watch()/read() zamiast prywatnych pól i _saveTasks().
# pubspec.yaml (fragment)
dependencies:
  flutter:
    sdk: flutter
  shared_preferences: ^2.2.2
  provider: ^6.0.5
// lib/task_store.dart
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';

class TaskStore extends ChangeNotifier {
  TaskStore._(this._prefs, this._tasks);

  static const _kTasks = 'tasks';
  final SharedPreferences _prefs;
  List<String> _tasks;

  static Future<TaskStore> create() async {
    final prefs = await SharedPreferences.getInstance();
    final items = prefs.getStringList(_kTasks) ?? <String>[];
    return TaskStore._(prefs, items);
  }

  List<String> get tasks => List.unmodifiable(_tasks);

  Future<void> add(String text) async {
    final t = text.trim();
    if (t.isEmpty) return;
    _tasks = List.of(_tasks)..insert(0, t);
    await _persist();
  }

  Future<void> updateAt(int index, String text) async {
    final t = text.trim();
    if (t.isEmpty || index < 0 || index >= _tasks.length) return;
    _tasks = List.of(_tasks)..[index] = t;
    await _persist();
  }

  Future<void> removeAt(int index) async {
    if (index < 0 || index >= _tasks.length) return;
    final copy = List.of(_tasks)..removeAt(index);
    _tasks = copy;
    await _persist();
  }

  Future<void> _persist() async {
    await _prefs.setStringList(_kTasks, _tasks);
    notifyListeners();
  }
}
// lib/main.dart (bootstrap z FutureBuilder)
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'task_store.dart';
import 'home_page.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const _AppBootstrap());
}

class _AppBootstrap extends StatelessWidget {
  const _AppBootstrap({super.key});

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<TaskStore>(
      future: TaskStore.create(),
      builder: (context, snap) {
        if (!snap.hasData) {
          return const MaterialApp(
            home: Scaffold(
              body: Center(child: CircularProgressIndicator()),
            ),
          );
        }
        return ChangeNotifierProvider.value(
          value: snap.data!,
          child: const MyApp(), // jak wcześniej
        );
      },
    );
  }
}
// lib/home_page.dart (fragmenty – użycie store'u)
import 'package:provider/provider.dart';
import 'task_store.dart';
import 'detail_page.dart';

// ...w build()
final store = context.watch<TaskStore>();
final tasks = store.tasks;

// dodawanie
FilledButton.icon(
  onPressed: () async {
    final t = _controller.text.trim();
    if (t.isEmpty) return;
    _controller.clear();
    await context.read<TaskStore>().add(t);
  },
  icon: const Icon(Icons.add),
  label: const Text('Dodaj'),
),

// lista
ListView.builder(
  itemCount: tasks.length,
  itemBuilder: (context, index) {
    final item = tasks[index];
    return Dismissible(
      key: ValueKey('task-$index-$item'),
      background: Container(color: Theme.of(context).colorScheme.errorContainer),
      onDismissed: (_) async => context.read<TaskStore>().removeAt(index),
      child: ListTile(
        title: Text(item),
        onTap: () async {
          final updated = await Navigator.of(context).push<String>(
            MaterialPageRoute(builder: (_) => DetailPage(initial: item)),
          );
          if (!context.mounted) return;
          if (updated == null) return;
          await context.read<TaskStore>().updateAt(index, updated);
        },
        trailing: IconButton(
          icon: const Icon(Icons.delete_outline),
          tooltip: 'Usuń',
          onPressed: () => context.read<TaskStore>().removeAt(index),
        ),
      ),
    );
  },
),

Kryteria po refaktoryzacji stanu

  • Po uruchomieniu widzisz te same dane co wcześniej; dodawanie/edycja/usuwanie działają identycznie.
  • Brak wywołań zapisu w build() (persist dzieje się wyłącznie w akcjach).
  • Hot Reload nie resetuje listy, a restart aplikacji zachowuje zadania.

Ostrzeżenia praktyczne (stan)

  • notifyListeners() wywołuj po skutecznym setStringList, żeby UI odzwierciedlał to, co realnie zapisane.
  • Nie twórz SharedPreferences w każdej metodzie – inicjalizuj raz w create().
  • Jeśli akcje stają się powolne, rozważ buforowanie i opóźniony zapis (debounce), ale nie w pierwszej wersji.

Motyw i wygląd: Material 3 z przełącznikiem jasny/ciemny

Kroki wdrożenia motywu aplikacji

  1. Włącz Material 3 oraz paletę z nasionem koloru (łatwe skalowanie barw).
    // lib/main.dart (fragment MyApp)
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        final themeMode = context.watch<ThemeController>().mode;
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          themeMode: themeMode,
          theme: ThemeData(
            useMaterial3: true,
            colorSchemeSeed: const Color(0xFF6750A4), // zmień na barwę marki
            brightness: Brightness.light,
          ),
          darkTheme: ThemeData(
            useMaterial3: true,
            colorSchemeSeed: const Color(0xFF6750A4),
            brightness: Brightness.dark,
          ),
          home: const HomePage(),
        );
      }
    }
    
  2. Dodaj prosty kontroler motywu jako osobny ChangeNotifier.
    // lib/theme_controller.dart
    import 'package:flutter/material.dart';
    
    class ThemeController extends ChangeNotifier {
      ThemeMode mode = ThemeMode.system;
      void toggle() {
        mode = mode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark;
        notifyListeners();
      }
    }
    
  3. Udostępnij kontroler w bootstrapie obok TaskStore.
    // lib/main.dart (bootstrap – rozszerzenie)
    return MultiProvider(
      providers: [
        ChangeNotifierProvider.value(value: snap.data!), // TaskStore
        ChangeNotifierProvider(create: (_) => ThemeController()),
      ],
      child: const MyApp(),
    );
    
  4. Dodaj przełącznik w UI (np. w AppBar). W małej aplikacji to wystarczy; w większej umieść w ekranie Ustawienia.
    // lib/home_page.dart (AppBar – fragment)
    actions: [
      IconButton(
        tooltip: 'Przełącz motyw',
        icon: Icon(
          switch (context.watch<ThemeController>().mode) {
            ThemeMode.dark => Icons.dark_mode,
            ThemeMode.light => Icons.light_mode,
            _ => Icons.brightness_auto,
          },
        ),
        onPressed: () => context.read<ThemeController>().toggle(),
      ),
    ],
    

Lista kontrolna (wygląd)

  • Przełącznik reaguje natychmiast; stan motywu nie resetuje listy zadań.
  • Kontrast tekstu w obu motywach jest wystarczający (przegląd kilku ekranów, różne tła/Surface).
  • Elementy stanu (SnackBar, przyciski) dziedziczą kolory z colorScheme, bez ręcznego nadpisywania na każdym widżecie.

Ostrzeżenia (wygląd)

  • Nie mieszaj wielu niezależnych ThemeData per ekran – spójniejszy i tańszy w utrzymaniu jest jeden motyw globalny.
  • Jeśli używasz ciemnego motywu, zweryfikuj kolor efektów (np. errorContainer) – czerwienie w dark potrafią „krzyczeć”.
  • Ikony w AppBarze powinny automatycznie zmieniać kolor; gdy nadpisujesz iconTheme, upewnij się, że kontrast pozostaje czytelny.
Jak zacząć z Flutterem: pierwsza aplikacja mobilna krok po kroku
Źródło: Pexels | Autor: Andrew Neel

Testy minimalne: szybka weryfikacja stanu i widżetów

Kroki: test jednostkowy dla TaskStore

  1. Skonfiguruj „puste” preferencje przed testem.
    // test/task_store_test.dart
    import 'package:flutter_test/flutter_test.dart';
    import 'package:shared_preferences/shared_preferences.dart';
    import 'package:your_app/task_store.dart';
    
    void main() {
      TestWidgetsFlutterBinding.ensureInitialized();
    
      test('add/update/remove utrzymują spójność listy', () async {
        SharedPreferences.setMockInitialValues({}); // izolacja testu
        final store = await TaskStore.create();
    
        await store.add(' A ');
        expect(store.tasks, ['A']);
    
        await store.updateAt(0, 'B');
        expect(store.tasks, ['B']);
    
        await store.removeAt(0);
        expect(store.tasks, isEmpty);
      });
    }
    
  2. Uruchom: flutter test. Błąd na tym etapie zwykle oznacza nieobsłużone białe znaki lub indeksy poza zakresem.

Kroki: test widżetu listy zadań

  1. Przetestuj dodanie elementu i odświeżenie UI.
    // test/home_page_test.dart
    import 'package:flutter/material.dart';
    import 'package:flutter_test/flutter_test.dart';
    import 'package:provider/provider.dart';
    import 'package:shared_preferences/shared_preferences.dart';
    
    import 'package:your_app/home_page.dart';
    import 'package:your_app/task_store.dart';
    import 'package:your_app/theme_controller.dart';
    
    void main() {
      TestWidgetsFlutterBinding.ensureInitialized();
    
      testWidgets('Dodanie zadania pojawia się na liście', (tester) async {
        SharedPreferences.setMockInitialValues({'tasks': <String>[]});
        final store = await TaskStore.create();
    
        await tester.pumpWidget(
          MultiProvider(
            providers: [
              ChangeNotifierProvider.value(value: store),
              ChangeNotifierProvider(create: (_) => ThemeController()),
            ],
            child: const MaterialApp(home: HomePage()),
          ),
        );
    
        // Wpisz tekst i kliknij "Dodaj"
        final field = find.byType(TextField);
        expect(field, findsOneWidget);
    
        await tester.enterText(field, 'Nowe zadanie');
        await tester.tap(find.widgetWithText(FilledButton, 'Dodaj'));
        await tester.pumpAndSettle();
    
        expect(find.text('Nowe zadanie'), findsOneWidget);
      });
    }
    

Lista kontrolna (testy)

  • Testy uruchamiają się lokalnie i w CI bez urządzenia (nie wymagają emulatora).
  • W testach widżetów używasz pumpAndSettle(), gdy akcja wyzwala animację lub Future.
  • Preferencje są „mockowane”, więc testy nie chłoną realnych danych.

Ostrzeżenia (testy)

  • Nie asercjonuj kolejności renderowania – sprawdzaj treść i zachowanie, nie szczegóły implementacji.
  • Jeżeli testy są niestabilne, ogranicz do minimum opóźnienia/animacje i jawnie czekaj na zakończenie Future.

Minimum do wydania: nazwa, ikona, podpisy i numeracja

Kroki konfiguracji produkcyjnej

  1. Nazwa aplikacji:
    • Android: android/app/src/main/res/values/strings.xml<string name="app_name">TwojaNazwa</string>.
    • iOS: ios/Runner/Info.plist – klucz CFBundleDisplayName.
  2. Ikona: użyj flutter_launcher_icons.
    # pubspec.yaml (fragment)
    dev_dependencies:
      flutter_launcher_icons: ^0.13.1
    
    flutter_icons:
      android: true
      ios: true
      image_path: assets/icon.png
    

    Uruchom: dart run flutter_launcher_icons.

  3. Numer wersji: w pubspec.yaml ustaw np. version: 1.0.0+1 (po + – build number).
  4. Android – podpis:
    # android/key.properties (plik lokalny, nie commituj)
    storePassword=...
    keyPassword=...
    keyAlias=upload
    storeFile=/ścieżka/keystore.jks
    
    // android/app/build.gradle (fragment)
    def keystoreProperties = new Properties()
    def keystorePropertiesFile = rootProject.file('key.properties')
    if (keystorePropertiesFile.exists()) {
      keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
    }
    
    android {
      signingConfigs {
        release {
          keyAlias keystoreProperties['keyAlias']
          keyPassword keystoreProperties['keyPassword']
          storeFile keystoreProperties['storeFile'] ?
    
    

Poprzedni artykułZgoda na przetwarzanie danych w projektach AI: kiedy jest potrzebna, a kiedy nie działa
Karolina Pawłowski
Karolina Pawłowski przygotowuje poradniki i analizy dotyczące narzędzi IT, produktywności oraz integracji usług. Jej styl pracy to „najpierw scenariusz, potem konfiguracja”: opisuje wymagania, warianty i konsekwencje wyborów, aby czytelnik mógł odtworzyć rozwiązanie u siebie. W recenzjach i porównaniach zwraca uwagę na ergonomię, stabilność, wsparcie producenta i koszty w dłuższym okresie. Dba o jasny język, ale nie upraszcza na siłę — techniczne szczegóły podaje wtedy, gdy są potrzebne.