Лекция 6. Flutter: Навигация и работа с данными

Навигация (Navigator)

Navigator 1.0: Основы

// Переход на новый экран
Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => const DetailScreen(),
  ),
);

// Возврат на предыдущий экран
Navigator.pop(context);

// Возврат данных
Navigator.pop(context, 'Результат');

// Получение результата
final result = await Navigator.push<String>(
  context,
  MaterialPageRoute(builder: (context) => const InputScreen()),
);
print('Получено: $result');
					

Navigator 2.0: Именованные маршруты

// В MaterialApp
MaterialApp(
  initialRoute: '/',
  routes: {
    '/': (context) => const HomeScreen(),
    '/detail': (context) => const DetailScreen(),
    '/settings': (context) => const SettingsScreen(),
  },
  onGenerateRoute: (settings) {
    if (settings.name == '/user') {
      final userId = settings.arguments as int;
      return MaterialPageRoute(
        builder: (context) => UserScreen(userId: userId),
      );
    }
    return null;
  },
);

// Переход
Navigator.pushNamed(context, '/detail');
Navigator.pushNamed(context, '/user', arguments: 42);
					

GoRouter: Современная навигация

// pub.dev/packages/go_router
final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/user/:id',
      builder: (context, state) {
        final id = int.parse(state.pathParameters['id']!);
        return UserScreen(userId: id);
      },
    ),
    ShellRoute(
      builder: (context, state, child) => MainShell(child: child),
      routes: [
        GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
        GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()),
      ],
    ),
  ],
);

// Переход
context.go('/user/42');
context.push('/detail');
					

GoRouter: Обработка ошибок

final router = GoRouter(
  routes: [...],
  errorBuilder: (context, state) => Scaffold(
    body: Center(
      child: Text('Ошибка: ${state.error}'),
    ),
  ),
);

// Редиректы
GoRouter(
  redirect: (context, state) {
    final isLoggedIn = authService.isLoggedIn;
    final isLoginRoute = state.matchedLocation == '/login';

    if (!isLoggedIn && !isLoginRoute) return '/login';
    if (isLoggedIn && isLoginRoute) return '/';
    return null;  // без редиректа
  },
);
					

Управление состоянием

Проблема состояния

  • Данные, которые меняются со временем (счётчик, список, авторизация)
  • Необходимо уведомлять UI об изменениях
  • Варианты решений:
    • setState - простой
    • Provider - рекомендуемый
    • Riverpod - современный
    • BLoC/Cubit - архитектурный
    • GetX - альтернативный

setState: Простой подход

class CounterScreen extends StatefulWidget {
  const CounterScreen({super.key});

  @override
  State<CounterScreen> createState() => _CounterScreenState();
}

class _CounterScreenState extends State<CounterScreen> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(child: Text('Счётчик: $_count')),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          setState(() { _count++; });
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}
					

Подходит для простых случаев. Не масштабируется.

Provider: Рекомендуемый подход

// 1. Определяем ChangeNotifier
class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();  // уведомляем слушателей
  }
}

// 2. Подключаем Provider (в корне приложения)
ChangeNotifierProvider(
  create: (_) => CounterModel(),
  child: MyApp(),
)

// 3. Используем
Consumer<CounterModel>(
  builder: (context, counter, child) {
    return Text('Счётчик: ${counter.count}');
  },
)

// 4. Изменяем
context.read<CounterModel>().increment();
					

Provider: Типы провайдеров

ПровайдерНазначениеПример
ProviderБазовый контейнерProvider(value: myService)
ChangeNotifierProviderДля ChangeNotifierChangeNotifierProvider(create:)
FutureProviderДля FutureFutureProvider(create:)
StreamProviderДля StreamStreamProvider(create:)
SelectorОптимизация (поиск)Selector<Model, int>(...)

Riverpod: Современный подход

// 1. Определяем провайдер
final counterProvider = StateNotifierProvider<CounterNotifier, int>(
  (ref) => CounterNotifier(),
);

class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0);

  void increment() => state++;
  void decrement() => state--;
}

// 2. Используем
class CounterScreen extends ConsumerWidget {
  const CounterScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Text('Счётчик: $count');
  }
}

// 3. Изменяем
ref.read(counterProvider.notifier).increment();
					

Riverpod: Автогенерация

// riverpod_generator (build_runner)
@riverpod
Future<List<User>> users(UsersRef ref) async {
  final response = await http.get(Uri.parse('https://api.example.com/users'));
  return (jsonDecode(response.body) as List).map((e) => User.fromJson(e)).toList();
}

// Использование
class UserList extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final usersAsync = ref.watch(usersProvider);
    return usersAsync.when(
      loading: () => CircularProgressIndicator(),
      error: (err, stack) => Text('Ошибка: $err'),
      data: (users) => ListView.builder(
        itemCount: users.length,
        itemBuilder: (_, i) => ListTile(title: Text(users[i].name)),
      ),
    );
  }
}
					

BLoC: Архитектурный подход

// Events
abstract class CounterEvent {}
class Increment extends CounterEvent {}
class Decrement extends CounterEvent {}

// Bloc
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<Increment>((event, emit) => emit(state + 1));
    on<Decrement>((event, emit) => emit(state - 1));
  }
}

// Использование
BlocProvider(
  create: (_) => CounterBloc(),
  child: BlocBuilder<CounterBloc, int>(
    builder: (context, count) {
      return Text('Счётчик: $count');
    },
  ),
)

// Изменение
context.read<CounterBloc>().add(Increment());
					

Сравнение подходов

ПодходСложностьТестируемостьКогда использовать
setStateМинимальнаяНизкаяПростые виджеты
ProviderНизкаяСредняяМаленькие-средние проекты
RiverpodСредняяВысокаяСовременные проекты
BLoCВысокаяОчень высокаяСложные архитектуры

Сетевые запросы

HTTP: Пакет http

import 'package:http/http.dart' as http;
import 'dart:convert';

class ApiService {
  final String baseUrl = 'https://jsonplaceholder.typicode.com';

  Future<List<Post>> getPosts() async {
    final response = await http.get(Uri.parse('$baseUrl/posts'));
    if (response.statusCode == 200) {
      final List<dynamic> data = jsonDecode(response.body);
      return data.map((json) => Post.fromJson(json)).toList();
    } else {
      throw Exception('Ошибка загрузки');
    }
  }

  Future<Post> createPost(Post post) async {
    final response = await http.post(
      Uri.parse('$baseUrl/posts'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode(post.toJson()),
    );
    return Post.fromJson(jsonDecode(response.body));
  }
}
					

Dio: Продвинутый HTTP-клиент

import 'package:dio/dio.dart';

final dio = Dio(BaseOptions(
  baseUrl: 'https://api.example.com',
  connectTimeout: const Duration(seconds: 5),
  receiveTimeout: const Duration(seconds: 3),
  headers: {'Authorization': 'Bearer token123'},
));

// Interceptor для логирования
dio.interceptors.add(LogInterceptor(responseBody: true));

// Interceptor для токена
dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) async {
    final token = await storage.read(key: 'token');
    options.headers['Authorization'] = 'Bearer $token';
    handler.next(options);
  },
  onError: (error, handler) async {
    if (error.response?.statusCode == 401) {
      final newToken = await refreshToken();
      error.requestOptions.headers['Authorization'] = 'Bearer $newToken';
      final response = await dio.fetch(error.requestOptions);
      return handler.resolve(response);
    }
    handler.next(error);
  },
));
					

Dio: Обработка ошибок

try {
  final response = await dio.get('/data');
} on DioException catch (e) {
  switch (e.type) {
    case DioExceptionType.connectionTimeout:
      print('Тайм-аут подключения');
    case DioExceptionType.receiveTimeout:
      print('Тайм-аут ответа');
    case DioExceptionType.badResponse:
      print('Ошибка сервера: ${e.response?.statusCode}');
      print('Тело: ${e.response?.data}');
    case DioExceptionType.cancel:
      print('Запрос отменён');
    case DioExceptionType.connectionError:
      print('Нет соединения');
    default:
      print('Неизвестная ошибка: ${e.message}');
  }
}
					

Модели данных (JSON)

// Ручной парсинг
class User {
  final int id;
  final String name;
  final String email;

  User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) {
    return User(
      id: json['id'] as int,
      name: json['name'] as String,
      email: json['email'] as String,
    );
  }

  Map<String, dynamic> toJson() => {
    'id': id,
    'name': name,
    'email': email,
  };
}

// Использование
final user = User.fromJson(responseJson);
					

json_serializable: Автогенерация

// pubspec.yaml
dependencies:
  json_annotation: ^4.9.0
dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.8.0

// Модель с аннотациями
@JsonSerializable()
class User {
  final int id;
  final String name;
  final DateTime createdAt;

  User({required this.id, required this.name, required this.createdAt});

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

// Генерация: dart run build_runner build
					

Локальное хранение данных

SharedPreferences

import 'package:shared_preferences/shared_preferences.dart';

// Сохранение
final prefs = await SharedPreferences.getInstance();
await prefs.setString('username', 'Иван');
await prefs.setInt('age', 25);
await prefs.setBool('dark_mode', true);
await prefs.setStringList('tags', ['dart', 'flutter']);

// Чтение
final username = prefs.getString('username') ?? 'Гость';
final age = prefs.getInt('age') ?? 0;
final tags = prefs.getStringList('tags') ?? [];

// Удаление
await prefs.remove('username');
await prefs.clear();  // удалить всё
					

SQLite (sqflite)

import 'package:sqflite/sqflite.dart';

// Создание БД
final database = openDatabase(
  'my_app.db',
  version: 1,
  onCreate: (db, version) {
    db.execute('''
      CREATE TABLE users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        email TEXT NOT NULL UNIQUE
      )
    ''');
  },
  onUpgrade: (db, oldVersion, newVersion) {
    if (oldVersion < 2) {
      db.execute('ALTER TABLE users ADD COLUMN age INTEGER DEFAULT 0');
    }
  },
);

// CRUD-операции
await database.insert('users', {'name': 'Иван', 'email': 'ivan@mail.ru'});
final users = await database.query('users');
await database.update('users', {'name': 'Пётр'}, where: 'id = ?', whereArgs: [1]);
await database.delete('users', where: 'id = ?', whereArgs: [1]);
					

Hive: Быстрое хранилище

import 'package:hive/hive.dart';

// Регистрация адаптера (генерируется через build_runner)
@HiveType(typeId: 0)
class User extends HiveObject {
  @HiveField(0)
  late String name;

  @HiveField(1)
  late String email;
}

// Инициализация
await Hive.initFlutter();
Hive.registerAdapter(UserAdapter());

// Открытие бокса
final box = await Hive.openBox<User>('users');

// CRUD
box.add(User()..name = 'Иван'..email = 'ivan@mail.ru');
final users = box.values.toList();
box.deleteAt(0);
box.clear();
					

Сравнение хранилищ

ХранилищеСкоростьФорматКогда использовать
SharedPreferencesБыстроKey-ValueНастройки, простые данные
sqfliteСреднеSQLСложные запросы, связи
HiveОчень быстроБинарныйКэш, быстрый доступ
IsarОчень быстроБинарныйNoSQL替代 Hive
ObjectBoxОчень быстроБинарныйNoSQL,高速增长

Platform Channels

Platform Channels

  • Механизм связи Dart-кода с нативным кодом (Kotlin/Swift)
  • Используется, когда нет Flutter-плагина для нужной функции
  • Типы:
    • MethodChannel - вызов методов
    • EventChannel - поток событий
    • BasicMessageChannel - простые сообщения

MethodChannel: Пример

// Dart (lib/main.dart)
const channel = MethodChannel('com.example/battery');

Future<int> getBatteryLevel() async {
  final level = await channel.invokeMethod<int>('getBatteryLevel');
  return level ?? -1;
}

// Слушатель событий от нативного кода
channel.setMethodCallHandler((call) async {
  if (call.method == 'onBatteryChanged') {
    final level = call.arguments as int;
    print('Battery level: $level');
  }
});
					

Platform Channel: Нативная реализация

// Kotlin (android/app/src/main/kotlin/.../MainActivity.kt)
class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.example/battery"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "getBatteryLevel" -> {
                        val level = getBatteryLevel()
                        result.success(level)
                    }
                    else -> result.notImplemented()
                }
            }
    }

    private fun getBatteryLevel(): Int {
        val batteryManager = getSystemService(BATTERY_SERVICE) as BatteryManager
        return batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
    }
}
					

Работа с ресурсами

Assets в Flutter

# pubspec.yaml
flutter:
  assets:
    - assets/images/
    - assets/data/config.json

# Загрузка изображения
Image.asset('assets/images/logo.png')

# Загрузка JSON
final data = await rootBundle.loadString('assets/data/config.json');
final config = jsonDecode(data);

# Загрузка изображения как байты
final data = await rootBundle.load('assets/images/logo.png');
final bytes = data.buffer.asUint8List();
					

Темы и стили

MaterialApp(
  theme: ThemeData(
    colorSchemeSeed: Colors.indigo,
    useMaterial3: true,
    brightness: Brightness.light,
    textTheme: const TextTheme(
      headlineMedium: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
      bodyLarge: TextStyle(fontSize: 16),
    ),
  ),
  darkTheme: ThemeData(
    colorSchemeSeed: Colors.indigo,
    useMaterial3: true,
    brightness: Brightness.dark,
  ),
  themeMode: ThemeMode.system,  // автоматическое переключение
)

// Использование
Text('Заголовок', style: Theme.of(context).textTheme.headlineMedium)
					

Кэширование изображений

// pub.dev/packages/cached_network_image
import 'package:cached_network_image/cached_network_image.dart';

CachedNetworkImage(
  imageUrl: 'https://example.com/photo.jpg',
  placeholder: (context, url) => CircularProgressIndicator(),
  errorWidget: (context, url, error) => Icon(Icons.error),
  fit: BoxFit.cover,
  width: 200,
  height: 200,
)

// Предзагрузка
await CachedNetworkImage.prefetch('https://example.com/photo.jpg');

// Очистка кэша
await DefaultCacheManager().emptyCache();
					

Navigator: Кастомные переходы

// FadeTransition
Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (_, __, ___) => const DetailScreen(),
    transitionsBuilder: (_, animation, __, child) {
      return FadeTransition(
        opacity: CurvedAnimation(
          parent: animation,
          curve: Curves.easeInOut,
        ),
        child: child,
      );
    },
  ),
);

// SlideTransition
Navigator.push(
  context,
  PageRouteBuilder(
    pageBuilder: (_, __, ___) => const DetailScreen(),
    transitionsBuilder: (_, animation, __, child) {
      final tween = Tween<Offset>(
        begin: const Offset(1.0, 0.0),
        end: Offset.zero,
      ).chain(CurveTween(curve: Curves.easeOut));

      return SlideTransition(
        position: animation.drive(tween),
        child: child,
      );
    },
  ),
);
					

Navigator: Вложенная навигация

// Navigator внутри вкладки
class TabScreen extends StatefulWidget {
  @override
  State<TabScreen> createState() => _TabScreenState();
}

class _TabScreenState extends State<TabScreen> {
  final _navigatorKey = GlobalKey<NavigatorState>();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Navigator(
        key: _navigatorKey,
        onGenerateRoute: (settings) {
          return MaterialPageRoute(
            builder: (_) => const HomeTab(),
          );
        },
      ),
      bottomNavigationBar: BottomNavigationBar(
        onTap: (index) {
          if (index == 0) {
            _navigatorKey.currentState!.pushNamed('/home');
          }
        },
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Главная'),
          BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Настройки'),
        ],
      ),
    );
  }
}
					

GoRouter: ShellRoute

// Постоянная нижняя навигация
final router = GoRouter(
  routes: [
    ShellRoute(
      builder: (context, state, child) {
        return ScaffoldWithNavBar(child: child);
      },
      routes: [
        GoRoute(
          path: '/home',
          builder: (_, __) => const HomeScreen(),
        ),
        GoRoute(
          path: '/profile',
          builder: (_, __) => const ProfileScreen(),
        ),
        GoRoute(
          path: '/settings',
          builder: (_, __) => const SettingsScreen(),
        ),
      ],
    ),
  ],
);

// Scaffold с навбаром
class ScaffoldWithNavBar extends StatelessWidget {
  final Widget child;
  const ScaffoldWithNavBar({required this.child});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: NavigationBar(
        selectedIndex: _calculateIndex(context),
        onDestinationSelected: (i) => _onItemTapped(i, context),
        destinations: const [
          NavigationDestination(icon: Icon(Icons.home), label: 'Главная'),
          NavigationDestination(icon: Icon(Icons.person), label: 'Профиль'),
          NavigationDestination(icon: Icon(Icons.settings), label: 'Настройки'),
        ],
      ),
    );
  }
}
					

GoRouter: Typed routes

// Типизированные маршруты (go_router_builder)
@TypedGoRoute<HomeRoute>(
  path: '/',
  routes: [
    TypedGoRoute<UserRoute>(path: 'user/:id'),
  ],
)
class HomeRoute extends GoRouteData {
  const HomeRoute();

  @override
  Widget build(BuildContext context, GoRouterState state) {
    return const HomeScreen();
  }
}

class UserRoute extends GoRouteData {
  final int id;
  const UserRoute({required this.id});

  @override
  Widget build(BuildContext context, GoRouterState state) {
    return UserScreen(userId: id);
  }
}

// Переход (типизированный)
context.go(const HomeRoute());
context.go(const UserRoute(id: 42));
					

Provider: MultiProvider

// Комбинирование нескольких провайдеров
MultiProvider(
  providers: [
    ChangeNotifierProvider(create: (_) => AuthService()),
    ChangeNotifierProvider(create: (_) => CartModel()),
    ChangeNotifierProvider(create: (_) => ThemeModel()),
    Provider<ApiService>(
      create: (_) => ApiService(),
    ),
  ],
  child: MyApp(),
)

// Доступ к разным провайдерам
class UserScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final auth = context.watch<AuthService>();
    final cart = context.watch<CartModel>();

    return Column(
      children: [
        Text('Привет, ${auth.currentUser.name}'),
        Text('В корзине: ${cart.items.length} товаров'),
      ],
    );
  }
}
					

Provider: ProxyProvider

// Зависимости между провайдерами
MultiProvider(
  providers: [
    Provider<DatabaseService>(create: (_) => DatabaseService()),
    ProxyProvider<DatabaseService, UserService>(
      update: (_, db, __) => UserService(db),
    ),
    ProxyProvider2<UserService, AuthService, OrderService>(
      update: (_, users, auth, __) => OrderService(users, auth),
    ),
  ],
)

// С自动 обновлением
class OrderScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // OrderService пересоздаётся при изменении UserService или AuthService
    final orderService = context.watch<OrderService>();
    return ListView(
      children: orderService.orders.map((o) =>
        ListTile(title: Text(o.id))
      ).toList(),
    );
  }
}
					

Riverpod: family modifier

// Параметризованные провайдеры
final userProvider = FutureProvider.family<User, int>((ref, userId) async {
  final response = await http.get(
    Uri.parse('https://api.example.com/users/$userId'),
  );
  return User.fromJson(jsonDecode(response.body));
});

// Использование с параметром
class UserScreen extends ConsumerWidget {
  final int userId;
  const UserScreen({required this.userId});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final userAsync = ref.watch(userProvider(userId));

    return userAsync.when(
      loading: () => CircularProgressIndicator(),
      error: (err, _) => Text('Ошибка: $err'),
      data: (user) => Text(user.name),
    );
  }
}

// Поиск с debounce
final searchProvider = FutureProvider.family<List<Product>, String>(
  (ref, query) async {
    if (query.isEmpty) return [];
    ref.onDispose(() {}); // debounce
    final results = await searchProducts(query);
    return results;
  },
);
					

Riverpod: autoDispose

// Автоматическая очистка
final userProvider = FutureProvider.autoDispose<User>((ref) async {
  // При уничтожении виджета провайдер очищается
  ref.onDispose(() {
    print('Провайдер очищен');
  });

  final response = await http.get(
    Uri.parse('https://api.example.com/user'),
  );
  return User.fromJson(jsonDecode(response.body));
});

// С тайм-аутом
final profileProvider =
    FutureProvider.autoDispose<Profile>((ref) async {
  // Отмена запроса через 30 секунд
  final cancelToken = CancelToken();
  ref.onDispose(() => cancelToken.cancel());

  final response = await dio.get(
    '/profile',
    cancelToken: cancelToken,
  );
  return Profile.fromJson(response.data);
});

// С кэшированием (keepAlive)
final configProvider =
    FutureProvider.autoDispose<Config>((ref) async {
  ref.keepAlive(); // не очищать автоматически
  final response = await http.get(Uri.parse('/config'));
  return Config.fromJson(jsonDecode(response.body));
});
					

BLoC: MultiBlocProvider

// Предоставление нескольких BLoC
MultiBlocProvider(
  providers: [
    BlocProvider<AuthBloc>(create: (_) => AuthBloc()),
    BlocProvider<CartBloc>(create: (_) => CartBloc()),
    BlocProvider<ThemeBloc>(
      create: (_) => ThemeBloc(),
    ),
  ],
  child: MyApp(),
)

// Доступ к разным BLoC
class MainScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        BlocBuilder<AuthBloc, AuthState>(
          builder: (context, state) {
            if (state is Authenticated) {
              return Text('Привет, ${state.user.name}');
            }
            return Text('Гость');
          },
        ),
        BlocBuilder<CartBloc, CartState>(
          builder: (context, state) {
            return Text('Товаров: ${state.items.length}');
          },
        ),
      ],
    );
  }
}
					

BLoC: BlocListener

// Побочные эффекты (навигация, snackbar)
BlocListener<AuthBloc, AuthState>(
  listener: (context, state) {
    if (state is AuthError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.message)),
      );
    }
    if (state is Authenticated) {
      Navigator.pushReplacement(
        context,
        MaterialPageRoute(builder: (_) => HomeScreen()),
      );
    }
  },
  child: LoginForm(),
)

// ComposedBlocListener + BlocBuilder
BlocConsumer<OrderBloc, OrderState>(
  listener: (context, state) {
    if (state is OrderSuccess) {
      Navigator.push(context, MaterialPageRoute(
        builder: (_) => ConfirmationScreen(),
      ));
    }
    if (state is OrderError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.message)),
      );
    }
  },
  builder: (context, state) {
    if (state is OrderLoading) {
      return CircularProgressIndicator();
    }
    return OrderForm();
  },
)
					

Networking: GraphQL

// pub.dev/packages/graphql_flutter
import 'package:graphql_flutter/graphql_flutter.dart';

// Настройка клиента
final httpLink = HttpLink('https://api.example.com/graphql');
final authLink = AuthLink(
  getToken: () async => 'Bearer $token',
);
final link = authLink.concat(httpLink);

final client = GraphQLClient(
  link: link,
  cache: GraphQLCache(store: InMemoryStore()),
);

// Запрос
String readRepositories = '''
  query ReadRepositories(\$first: Int) {
    repositories(first: \$first) {
      nodes {
        name
        description
      }
    }
  }
''';

Query(
  options: QueryOptions(
    document: gql(readRepositories),
    variables: {'first': 10},
  ),
  builder: (result, {fetchMore, refetch}) {
    if (result.isLoading) return CircularProgressIndicator();
    if (result.hasException) return Text('Ошибка');
    final repos = result.data?['repositories']['nodes'];
    return ListView.builder(
      itemCount: repos.length,
      itemBuilder: (_, i) => ListTile(title: Text(repos[i]['name'])),
    );
  },
)
					

Networking: WebSocket

// pub.dev/packages/web_socket_channel
import 'package:web_socket_channel/web_socket_channel.dart';

// Подключение
final channel = WebSocketChannel.connect(
  Uri.parse('wss://echo.websocket.org'),
);

// Отправка и получение
channel.sink.add('Привет, сервер!');
channel.stream.listen(
  (message) => print('Ответ: $message'),
  onDone: () => print('Соединение закрыто'),
  onError: (error) => print('Ошибка: $error'),
);

// Закрытие
channel.sink.close();

// Чат-приложение (пример)
class ChatScreen extends StatefulWidget {
  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  late WebSocketChannel channel;
  final messages = <String>[];

  @override
  void initState() {
    super.initState();
    channel = WebSocketChannel.connect(
      Uri.parse('wss://chat.example.com/ws'),
    );
    channel.stream.listen((data) {
      setState(() => messages.add(data.toString()));
    });
  }

  @override
  void dispose() {
    channel.sink.close();
    super.dispose();
  }
}
					

Networking: Retrofit

// pub.dev/packages/retrofit
import 'package:retrofit/http.dart';
import 'package:dio/dio.dart';

part 'api_client.g.dart';

@RestApi(baseUrl: 'https://api.example.com')
abstract class ApiClient {
  factory ApiClient(Dio dio, {String baseUrl}) = _ApiClient;

  @GET('/users')
  Future<List<User>> getUsers();

  @GET('/users/{id}')
  Future<User> getUser(@Path('id') int id);

  @POST('/users')
  Future<User> createUser(@Body() User user);

  @PUT('/users/{id}')
  Future<User> updateUser(@Path('id') int id, @Body() User user);

  @DELETE('/users/{id}')
  Future<void> deleteUser(@Path('id') int id);
}

// Использование
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
final client = ApiClient(dio);

final users = await client.getUsers();
final user = await client.getUser(42);
					

Local Storage: Drift

// pub.dev/packages/drift (formerly moor)
import 'package:drift/drift.dart';

part 'database.g.dart';

class Users extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text().withLength(min: 1, max: 50)();
  TextColumn get email => text().unique()();
}

@DriftDatabase(tables: [Users])
class AppDatabase extends _$AppDatabase {
  AppDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  Future<int> insertUser(UsersCompanion entry) {
    return into(users).insert(entry);
  }

  Future<List<User>> getAllUsers() {
    return select(users).get();
  }

  Stream<List<User>> watchAllUsers() {
    return select(users).watch();
  }

  Future<bool> updateUser(UsersCompanion entry) {
    return update(users).replace(entry);
  }

  Future<int> deleteUser(int id) {
    return (delete(users)..where((t) => t.id.equals(id))).go();
  }
}
					

Local Storage: Isar

// pub.dev/packages/isar
import 'package:isar/isar.dart';

part 'user.g.dart';

@collection
class User {
  Id id = Isar.autoIncrement;
  late String name;
  late String email;
  late DateTime createdAt;
}

// Инициализация
final isar = await Isar.open([UserSchema]);

// Запись
await isar.writeTxn(() async {
  final user = User()
    ..name = 'Иван'
    ..email = 'ivan@mail.ru'
    ..createdAt = DateTime.now();
  await isar.users.put(user);
});

// Запросы
final users = await isar.users
    .where()
    .nameEqualTo('Иван')
    .findAll();

// Поток обновлений
isar.users.where().watch().listen((users) {
  print('Обновлено: ${users.length} пользователей');
});

// Индексы и фильтрация
final sortedUsers = await isar.users
    .where()
    .sortByCreatedAtDesc()
    .findAll();
					

Caching: dio_cache_interceptor

// pub.dev/packages/dio_cache_interceptor
import 'package:dio_cache_interceptor/dio_cache_interceptor.dart';

final dio = Dio();

// Настройка кэширования
final cacheOptions = CacheOptions(
  store: MemCacheStore(maxSize: 1024 * 1024, maxEntrySize: 50 * 1024),
  policy: CachePolicy.forceCache,
  maxStale: const Duration(days: 7),
  hitCacheOnErrorExcept: [401, 403],
);

dio.interceptors.add(DioCacheInterceptor(options: cacheOptions));

// Использование
Future<Response> getData() async {
  return dio.get(
    'https://api.example.com/data',
    options: Options(
      extra: {
        'cache': true, // кэшировать ответ
      },
    ),
  );
}

// Очистка кэша
await cacheOptions.store?.clean();

// Разные политики
// CachePolicy.forceCache — всегда использовать кэш
// CachePolicy.forceCacheOnError — кэш при ошибке
// CachePolicy.noCache — не кэшировать
// CachePolicy.refresh — обновить кэш
					

Forms: Flutter Form

class LoginForm extends StatefulWidget {
  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            controller: _emailController,
            validator: (value) {
              if (value == null || value.isEmpty) return 'Введите email';
              if (!value.contains('@')) return 'Некорректный email';
              return null;
            },
            decoration: InputDecoration(labelText: 'Email'),
          ),
          TextFormField(
            controller: _passwordController,
            obscureText: true,
            validator: (value) {
              if (value == null || value.length < 6) {
                return 'Минимум 6 символов';
              }
              return null;
            },
            decoration: InputDecoration(labelText: 'Пароль'),
          ),
          ElevatedButton(
            onPressed: () {
              if (_formKey.currentState!.validate()) {
                _login();
              }
            },
            child: Text('Войти'),
          ),
        ],
      ),
    );
  }
}
					

Forms: Input Decoration

TextFormField(
  decoration: InputDecoration(
    labelText: 'Email',
    hintText: 'example@mail.ru',
    prefixIcon: Icon(Icons.email),
    suffixIcon: IconButton(
      icon: Icon(Icons.clear),
      onPressed: () => _controller.clear(),
    ),
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
    ),
    enabledBorder: OutlineInputBorder(
      borderSide: BorderSide(color: Colors.grey),
    ),
    focusedBorder: OutlineInputBorder(
      borderSide: BorderSide(color: Colors.blue, width: 2),
    ),
    errorBorder: OutlineInputBorder(
      borderSide: BorderSide(color: Colors.red),
    ),
    filled: true,
    fillColor: Colors.grey[100],
    contentPadding: EdgeInsets.symmetric(
      horizontal: 16, vertical: 12,
    ),
  ),
)

// Label + floating label
InputDecoration(
  labelText: 'Имя',
  labelStyle: TextStyle(color: Colors.grey),
  floatingLabelStyle: TextStyle(color: Colors.blue),
)
					

Error handling: Result types

// pub.dev/packages/fpdart
import 'package:fpdart/fpdart.dart';

// Either для обработки ошибок
TaskEither<String, User> fetchUser(int id) {
  return TaskEither.tryCatch(
    () async {
      final response = await http.get(
        Uri.parse('https://api.example.com/users/$id'),
      );
      if (response.statusCode == 200) {
        return User.fromJson(jsonDecode(response.body));
      }
      throw Exception('Ошибка сервера');
    },
    (error, stackTrace) => 'Ошибка загрузки: $error',
  );
}

// Использование
final result = await fetchUser(42).run();
result.match(
  (error) => print('Ошибка: $error'),
  (user) => print('Пользователь: ${user.name}'),
);

// Цепочки операций
TaskEither<String, User> updateUser(int id, String name) {
  return fetchUser(id)
      .map((user) => user.copyWith(name: name))
      .flatMap((user) => saveUser(user));
}
					

Serialization: freezed models

// pub.dev/packages/freezed
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    required String email,
    @Default(0) int age,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) =>
      _$UserFromJson(json);
}

// Иммутабельные данные
final user1 = User(id: 1, name: 'Иван', email: 'ivan@mail.ru');
final user2 = user1.copyWith(name: 'Пётр'); // новый экземпляр

// Сравнение
print(user1 == user2); // false (разные имена)

// Pattern matching
final greeting = switch (user) {
  User(age: < 18) => 'Несовершеннолетний',
  User(age: >= 18) => 'Совершеннолетний',
};

// Union types
@freezed
sealed class AuthState with _$AuthState {
  const factory AuthState.idle() = AuthIdle;
  const factory AuthState.loading() = AuthLoading;
  const factory AuthState.authenticated(User user) = AuthAuthenticated;
  const factory AuthState.error(String message) = AuthError;
}
					

Serialization: equatable

// pub.dev/packages/equatable
import 'package:equatable/equatable.dart';

class User extends Equatable {
  final int id;
  final String name;
  final String email;

  const User({
    required this.id,
    required this.name,
    required this.email,
  });

  @override
  List<Object?> get props => [id, name, email];
}

// Без Equatable
final user1 = User(id: 1, name: 'Иван', email: 'ivan@mail.ru');
final user2 = User(id: 1, name: 'Иван', email: 'ivan@mail.ru');
print(user1 == user2); // false (сравнение ссылок)

// С Equatable
print(user1 == user2); // true (сравнение полей)

// Важно для BLoC/Cubit
class UserCubit extends Cubit<User> {
  UserCubit() : super(const User(id: 0, name: '', email: ''));

  void updateName(String name) {
    emit(state.copyWith(name: name)); // без Equatable не сработает
  }
}
					

Image handling: image_picker

// pub.dev/packages/image_picker
import 'package:image_picker/image_picker.dart';

class ImagePickerScreen extends StatefulWidget {
  @override
  State<ImagePickerScreen> createState() => _ImagePickerScreenState();
}

class _ImagePickerScreenState extends State<ImagePickerScreen> {
  XFile? _image;

  Future<void> _pickImage(ImageSource source) async {
    final picker = ImagePicker();
    final pickedFile = await picker.pickImage(
      source: source,
      maxWidth: 1800,
      maxHeight: 1800,
      imageQuality: 85,
    );

    if (pickedFile != null) {
      setState(() => _image = pickedFile);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        if (_image != null)
          Image.file(File(_image!.path), height: 200),

        ElevatedButton.icon(
          onPressed: () => _pickImage(ImageSource.camera),
          icon: Icon(Icons.camera_alt),
          label: Text('Камера'),
        ),
        ElevatedButton.icon(
          onPressed: () => _pickImage(ImageSource.gallery),
          icon: Icon(Icons.photo_library),
          label: Text('Галерея'),
        ),
      ],
    );
  }
}
					

Итоги лекции 6

Навигация

  • Navigator 1.0 и 2.0
  • GoRouter (рекомендуемый)
  • Именованные маршруты

Состояние

  • setState (простой)
  • Provider / Riverpod
  • BLoC

Сеть

  • http (базовый)
  • Dio (продвинутый)
  • JSON модели

Хранение

  • SharedPreferences
  • SQLite (sqflite)
  • Hive