Привет! Продолжаю выкладывать перевод статьи, которую я использовал как основу для реализации социального функционала в нашем проекте Dom24x7, где люди могут общаться друг с другом, решать возникающие бытовые проблемы, а также взаимодействовать с УК/ТСЖ. Первую часть статьи можно прочитать тут.

Пишем код для нашего инстаграм клона

Наконец, мы добрались до самой интригующей части статьи.

Перед тем как продолжить, давайте быстро обсудим подход, с которым мы подойдем к созданию нашего клона.

Наш Stream-agram будет состоять из нескольких основных компонентов UI, от которых мы будем отталкиваться позже в статье. Это такие компоненты как:

  • Экран входа (mock-объект): там, где мы входим в свой профиль;

  • Главный экран: откуда мы сможем переходить в другие области нашего приложения (по умолчанию там будет открыта хронологическая лента);

  • Саму страницу с хронологической лентой (домашняя вкладка): место, где пользователь смотрит посты тех на кого подписан;

  • Вкладку личного профиля: там, где представлена вся информация о профиле, включая опубликованные посты;

  • Вкладку поиска: откуда пользователь может подписываться и отписываться от других пользователей;

  • Экран «Редактировать профиль»: там, где пользователь может изменить основную информацию профиля и основное фото;

  • Экран «Новая публикация»: там, где пользователь может выкладывать новые посты (действия) в свой профиль.

Мы начнем с упрощенных версий этих вкладок и страниц и будем вносить в них изменения шаг за шагом, по мере того как будем добавлять больше функций в наше приложений. Конечным результатом станет практически идентичный клон Instagram*.

Файл Flutter и структура папок

Каждое приложение является уникальным и потребует своей собственной структуры папок. Ту структуру, которую мы используем в данной статье, ни в коем случае нельзя считать лучше какой-либо другой. Это лишь один из удобных способов того, как можно организовать код для конкретно нашего приложения.

Это приложение группирует весь код под две папки верхнего уровня: app и components.

В components находится все, что будет представлено в пользовательском интерфейсе - его компоненты. Другими словами это будут кусочки приложения, которые смогут работать независимо от других его частей; например - страницы/вкладки, кнопки, и виджеты. Каталог component будет также содержать папку с именем app_widgets, которая будет содержать все общие виджеты, используемые в приложении.

Каталог app содержит классы, относящиеся ко всему приложению, а также код, который будет использован множеством компонентов. Например, app state и navigation.

Для того, чтобы аккуратно организовать импортируемые элементы, в данной инструкции мы используем файлы Barrel (далее просто - баррель-файлы).

Баррель-файлы позволяют свернуть импортируемые элементы из нескольких папок в один удобный файл.

К концу статьи структура ваших папок и файлов должна иметь похожий вид. Вы можете либо создать всё это сейчас самостоятельно, либо позже по мере прохождения статьи.

├── lib
|   ├── app
│   │   ├── navigation
│   │   │   └── custom_rect_tween.dart
|   |   |   └── hero_dialog_route.dart
|   |   |   └── navigation.dart *
|   |   ├── state
|   |   |   ├── models
|   |   |   |   └── models.dart *
|   |   |   |   └── user.dart
|   |   |   └── app_state.dart
|   |   |   └── demo_users.dart
|   |   |   └── state.dart *
|   |   └── app.dart *
|   |   └── stream_agram.dart
|   |   └── theme.dart
|   |   └── utils.dart
|   ├── components
│   │   ├── app_widgets
│   │   │   └── app_widgets.dart *
|   |   |   └── avatars.dart
|   |   |   └── comment_box.dart
|   |   |   └── favorite_icon.dart
|   |   |   └── tap_fade_icon.dart
|   |   ├── comments
|   |   |   ├── state
|   |   |   |   └── comment_state.dart *
|   |   |   |   └── state.dart
|   |   |   └── comment_screen.dart
|   |   |   └── comments.dart *
│   │   ├── home
│   │   │   └── home_screen.dart
|   |   |   └── home.dart *
|   |   ├── login
|   |   |   └── login_screen.dart
|   |   |   └── login.dart *
│   │   ├── new_post
│   │   │   └── new_post_screen.dart
|   |   |   └── new_post.dart *
|   |   ├── profile
|   |   |   └── edit_profile_screen.dart
|   |   |   └── profile_page.dart
|   |   |   └── profile.dart *
│   │   ├── search
│   │   │   └── search_page.dart
|   |   |   └── search.dart *
|   |   └── timeline
|   |   |   ├── widgets
|   |   |   |   └── post_card.dart
|   |   |   |   └── widgets.dart *
|   |       └── timeline_page.dart
|   |       └── timeline.dart *
│   └── main.dart
├── pubspec.lock
├── pubspec.yaml

⚠️ Заметьте, что баррель-файлы помечены звездочкой «*»

Создаем пользователей для нашего Instagram

Как уже было упомянуто выше, в целях демонстрации, в данной статье мы захардкодим наших пользователей, то есть заполним данные для них вручную.

Создайте файл app/state/demo_users.dart и добавьте в него следующее:

import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';

/// Demo application users.
enum DemoAppUser {
  sahil,
  sacha,
  reuben,
  gordon,
}

/// Convenient class Extension on [DemoAppUser] enum
extension DemoAppUserX on DemoAppUser {
  /// Convenient method Extension to generate an [id] from [DemoAppUser] enum
  String? get id => {
        DemoAppUser.sahil: 'sahil-kumar',
        DemoAppUser.sacha: 'sacha-arbonel',
        DemoAppUser.reuben: 'reuben-turner',
        DemoAppUser.gordon: 'gordon-hayes',
      }[this];

  /// Convenient method Extension to generate a [name] from [DemoAppUser] enum
  String? get name => {
        DemoAppUser.sahil: 'Sahil Kumar',
        DemoAppUser.sacha: 'Sacha Arbonel',
        DemoAppUser.reuben: 'Reuben Turner',
        DemoAppUser.gordon: 'Gordon Hayes',
      }[this];

  /// Convenient method Extension to generate [data] from [DemoAppUser] enum
  Map<String, Object>? get data => {
        DemoAppUser.sahil: {
          'first_name': 'Sahil',
          'last_name': 'Kumar',
          'full_name': 'Sahil Kumar',
        },
        DemoAppUser.sacha: {
          'first_name': 'Sacha',
          'last_name': 'Arbonel',
          'full_name': 'Sacha Arbonel',
        },
        DemoAppUser.reuben: {
          'first_name': 'Reuben',
          'last_name': 'Turner',
          'full_name': 'Reuben Turner',
        },
        DemoAppUser.gordon: {
          'first_name': 'Gordon',
          'last_name': 'Hayes',
          'full_name': 'Gordon Hayes',
        },
      }[this];

  /// Convenient method Extension to generate a [token] from [DemoAppUser] enum
  Token? get token => <DemoAppUser, Token>{
        DemoAppUser.sahil: const Token(''), // TODO add token
        DemoAppUser.sacha: const Token(''), // TODO add token
        DemoAppUser.reuben: const Token(''), // TODO add token
        DemoAppUser.gordon: const Token(''), // TODO add token
      }[this];
}

В данном файле присутствуют несколько TODO комментариев, поскольку нам нужно добавить String токены, созданные ранее. Обязательно добавьте правильные токены для нужных пользователей.

Если вы используете другие имена пользователей, то вам также понадобится изменить соответствующие строки в данном файле.

Код выше содержит в себе enum класс DemoAppUser c расширением DemoAppUserX. Сделано это для того, чтобы мы могли получить доступ к другим данным (например к токенам или к именам пользователей). Этот файл не является критично важным, и вы можете создать ваших фейковых пользователей любым другим способом.

Создайте баррель-файл командой app/state/state.dart и экспортируйте его:

export 'demo_users.dart';

Далее, добавьте баррель-файл уровня выше, который имеет вид app/app.dart экспортируйте его:

export 'state/state.dart';

Создаем темы для нашего инстраграм клона

Мы собираемся использовать те же цвета и шрифты, которые вы видите при использовании настоящего Инстаграма, для того чтобы наше приложение давало пользователям те же эмоции, при использовании. Мы также хотим отметить, что в нашем клоне будет присутствовать дневной ☀️ и ночной ???? режимы. Основный режим будет зависеть от настроек платформы. В приложении не будет реализована смена режимов в зависимости от дневного времени, поскольку это слегка выходит за рамки основной цели нашей статьи.

Создайте app/theme.dart и добавьте следующее:

import 'package:flutter/material.dart';

/// Global reference to application colors.
abstract class AppColors {
  /// Dark color.
  static const dark = Colors.black;

  static const light = Color(0xFFFAFAFA);

  /// Grey background accent.
  static const grey = Color(0xFF262626);

  /// Primary text color
  static const primaryText = Colors.white;

  /// Secondary color.
  static const secondary = Color(0xFF0095F6);

  /// Color to use for favorite icons (indicating a like).
  static const like = Colors.red;

  /// Grey faded color.
  static const faded = Colors.grey;

  /// Light grey color
  static const ligthGrey = Color(0xFFEEEEEE);

  /// Top gradient color used in various UI components.
  static const topGradient = Color(0xFFE60064);

  /// Bottom gradient color used in various UI components.
  static const bottomGradient = Color(0xFFFFB344);
}

/// Global reference to application [TextStyle]s.
abstract class AppTextStyle {
  /// A medium bold text style.
  static const textStyleBoldMedium = TextStyle(
    fontWeight: FontWeight.w600,
  );

  /// A bold text style.
  static const textStyleBold = TextStyle(
    fontWeight: FontWeight.bold,
  );

  static const textStyleSmallBold = TextStyle(
    fontWeight: FontWeight.bold,
    fontSize: 13,
  );

  /// A faded text style. Uses [AppColors.faded].
  static const textStyleFaded =
      TextStyle(color: AppColors.faded, fontWeight: FontWeight.w400);

  /// A faded text style. Uses [AppColors.faded].
  static const textStyleFadedSmall = TextStyle(
      color: AppColors.faded, fontWeight: FontWeight.w400, fontSize: 11);

  /// A faded text style. Uses [AppColors.faded].
  static const textStyleFadedSmallBold = TextStyle(
      color: AppColors.faded, fontWeight: FontWeight.w500, fontSize: 11);

  /// Light text style.
  static const textStyleLight = TextStyle(fontWeight: FontWeight.w300);

  /// Action text
  static const textStyleAction = TextStyle(
    fontWeight: FontWeight.w700,
    color: AppColors.secondary,
  );
}

/// Global reference to the application theme.
class AppTheme {
  final _darkBase = ThemeData.dark();
  final _lightBase = ThemeData.light();

  /// Dark theme and its settings.
  ThemeData get darkTheme => _darkBase.copyWith(
        visualDensity: VisualDensity.adaptivePlatformDensity,
        backgroundColor: AppColors.dark,
        scaffoldBackgroundColor: AppColors.dark,
        appBarTheme: _darkBase.appBarTheme.copyWith(
          backgroundColor: AppColors.dark,
          foregroundColor: AppColors.light,
          iconTheme: const IconThemeData(color: AppColors.light),
          elevation: 0,
        ),
        bottomNavigationBarTheme: _darkBase.bottomNavigationBarTheme.copyWith(
          backgroundColor: AppColors.dark,
          selectedItemColor: AppColors.light,
        ),
        outlinedButtonTheme: OutlinedButtonThemeData(
          style: ButtonStyle(
            side: MaterialStateProperty.all(
              const BorderSide(
                color: AppColors.grey,
              ),
            ),
            foregroundColor: MaterialStateProperty.all<Color>(
              AppColors.light,
            ),
            backgroundColor: MaterialStateProperty.all<Color>(
              AppColors.dark,
            ),
            overlayColor: MaterialStateProperty.all<Color>(
              AppColors.grey,
            ),
          ),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ButtonStyle(
            backgroundColor: MaterialStateProperty.all<Color>(
              AppColors.secondary,
            ),
            foregroundColor: MaterialStateProperty.all<Color>(
              AppColors.primaryText,
            ),
            overlayColor: MaterialStateProperty.all<Color>(
              AppColors.grey,
            ),
          ),
        ),
        textButtonTheme: TextButtonThemeData(
          style: ButtonStyle(
            foregroundColor: MaterialStateProperty.all<Color>(
              AppColors.secondary,
            ),
            overlayColor: MaterialStateProperty.all<Color>(
              AppColors.grey,
            ),
            textStyle: MaterialStateProperty.all<TextStyle>(
              const TextStyle(
                color: AppColors.secondary,
                fontSize: 16,
                fontWeight: FontWeight.w600,
              ),
            ),
          ),
        ),
        brightness: Brightness.dark,
        colorScheme:
            _darkBase.colorScheme.copyWith(secondary: AppColors.secondary),
      );

  ThemeData get lightTheme => _lightBase.copyWith(
        visualDensity: VisualDensity.adaptivePlatformDensity,
        backgroundColor: AppColors.light,
        scaffoldBackgroundColor: AppColors.light,
        appBarTheme: _lightBase.appBarTheme.copyWith(
          backgroundColor: AppColors.light,
          foregroundColor: AppColors.dark,
          iconTheme: const IconThemeData(color: AppColors.dark),
          elevation: 0,
        ),
        bottomNavigationBarTheme: _lightBase.bottomNavigationBarTheme.copyWith(
          backgroundColor: AppColors.light,
          selectedItemColor: AppColors.dark,
        ),
        snackBarTheme:
            _lightBase.snackBarTheme.copyWith(backgroundColor: AppColors.dark),
        outlinedButtonTheme: OutlinedButtonThemeData(
          style: ButtonStyle(
            side: MaterialStateProperty.all(
              const BorderSide(
                color: AppColors.ligthGrey,
              ),
            ),
            foregroundColor: MaterialStateProperty.all<Color>(
              AppColors.dark,
            ),
            backgroundColor: MaterialStateProperty.all<Color>(
              AppColors.light,
            ),
            overlayColor: MaterialStateProperty.all<Color>(
              AppColors.ligthGrey,
            ),
          ),
        ),
        elevatedButtonTheme: ElevatedButtonThemeData(
          style: ButtonStyle(
            backgroundColor: MaterialStateProperty.all<Color>(
              AppColors.secondary,
            ),
            foregroundColor: MaterialStateProperty.all<Color>(
              AppColors.primaryText,
            ),
            overlayColor: MaterialStateProperty.all<Color>(
              AppColors.ligthGrey,
            ),
          ),
        ),
        textButtonTheme: TextButtonThemeData(
          style: ButtonStyle(
            foregroundColor: MaterialStateProperty.all<Color>(
              AppColors.secondary,
            ),
            textStyle: MaterialStateProperty.all<TextStyle>(
              const TextStyle(
                color: AppColors.secondary,
                fontSize: 16,
                fontWeight: FontWeight.w600,
              ),
            ),
            overlayColor: MaterialStateProperty.all<Color>(
              AppColors.ligthGrey,
            ),
          ),
        ),
        brightness: Brightness.light,
        colorScheme:
            _lightBase.colorScheme.copyWith(secondary: AppColors.secondary),
      );
}

Вы можете не торопиться и изучить этот код в своем собственном темпе, или подробно остановиться на нем позже, когда мы будет непосредственно использовать некоторые из терминов, содержащихся в нем.

В баррель-файле state/state.dart добавьте следующие строки:

export 'state/state.dart';
export 'theme.dart'; // ADD THIS

Модели

Можете ли вы поверить в то, что в таком огромном приложении нам понадобится создать с нуля всего лишь один класс модели? ????

Вы можете в этом не сомневаться, поскольку большинство моделей, которые нам понадобятся будут взяты из стандартных пакетов Stream Feed.

Создайте app/state/models/user.dart и добавьте следующее:

import 'dart:convert';

import 'package:flutter/material.dart';

/// Data model for a feed user's extra data.
@immutable
class StreamagramUser {
  /// Data model for a feed user's extra data.
  const StreamagramUser({
    required this.firstName,
    required this.lastName,
    required this.fullName,
    required this.profilePhoto,
    required this.profilePhotoResized,
    required this.profilePhotoThumbnail,
  });

  /// Converts a Map to this.
  factory StreamagramUser.fromMap(Map<String, dynamic> map) {
    return StreamagramUser(
      firstName: map['first_name'] as String,
      lastName: map['last_name'] as String,
      fullName: map['full_name'] as String,
      profilePhoto: map['profile_photo'] as String?,
      profilePhotoResized: map['profile_photo_resized'] as String?,
      profilePhotoThumbnail: map['profile_photo_thumbnail'] as String?,
    );
  }

  /// Converts json to this.
  factory StreamagramUser.fromJson(String source) =>
      StreamagramUser.fromMap(json.decode(source) as Map<String, dynamic>);

  /// User's first name
  final String firstName;

  /// User's last name
  final String lastName;

  /// User's full name
  final String fullName;

  /// URL to user's profile photo.
  final String? profilePhoto;

  /// A 500x500 version of the [profilePhoto].
  final String? profilePhotoResized;

  /// A small thumbnail version of the [profilePhoto].
  final String? profilePhotoThumbnail;

  /// Convenient method to replace certain fields.
  StreamagramUser copyWith({
    String? firstName,
    String? lastName,
    String? fullName,
    String? profilePhoto,
    String? profilePhotoResized,
    String? profilePhotoThumbnail,
  }) {
    return StreamagramUser(
      firstName: firstName ?? this.firstName,
      lastName: lastName ?? this.lastName,
      fullName: fullName ?? this.fullName,
      profilePhoto: profilePhoto ?? this.profilePhoto,
      profilePhotoResized: profilePhotoResized ?? this.profilePhotoResized,
      profilePhotoThumbnail:
          profilePhotoThumbnail ?? this.profilePhotoThumbnail,
    );
  }

  /// Converts this model to a Map.
  Map<String, dynamic> toMap() {
    return {
      'first_name': firstName,
      'last_name': lastName,
      'full_name': fullName,
      'profile_photo': profilePhoto,
      'profile_photo_resized': profilePhotoResized,
      'profile_photo_thumbnail': profilePhotoThumbnail,
    };
  }

  /// Converts this class to json.
  String toJson() => json.encode(toMap());

  @override
  String toString() {
    return '''UserData(firstName: $firstName, lastName: $lastName, fullName: $fullName, profilePhoto: $profilePhoto, profilePhotoResized: $profilePhotoResized, profilePhotoThumbnail: $profilePhotoThumbnail)''';
  }

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) return true;

    return other is StreamagramUser &&
        other.firstName == firstName &&
        other.lastName == lastName &&
        other.fullName == fullName &&
        other.profilePhoto == profilePhoto &&
        other.profilePhotoResized == profilePhotoResized &&
        other.profilePhotoThumbnail == profilePhotoThumbnail;
  }

  @override
  int get hashCode {
    return firstName.hashCode ^
        lastName.hashCode ^
        fullName.hashCode ^
        profilePhoto.hashCode ^
        profilePhotoResized.hashCode ^
        profilePhotoThumbnail.hashCode;
  }
}

Это будет вашим пользовательским классом данным (User Data Class). Он хранит в себе ряд полей и предоставляет такие удобные методы как: toMap, fromMap, и copyWith. Они пригодятся нам позже. Мы создаем этот класс по одной причине - чтобы мы могли легко создавать объекты из дополнительных данных, которые будут хранится для каждого пользователя Stream Feed.

Хоть у нас всего и одна модель, мы не будем подавать плохой пример и все равно создадим наш баррель-файл.

Создайте app/state/models/models.dart и добавьте следующее:

export 'user.dart';

В баррель-файле app/state/state.dart добавьте следующие строки:

export 'demo_users.dart';
export 'app_state.dart';
export 'models/models.dart'; // ADD THIS

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

Предмет вечных споров.

Для нашего инстаграм клона нам понадобится управлять только базовым состоянием приложения так, чтобы мы могли легко вносить изменения в наш UI.

Большая часть состояния будет управляться с помощью пакета stream_feed_flutter_core package. Однако нам все равно необходим легкий способ чтобы передавать состояние в нашем приложении (например - авторизованных пользователей).

В этой статье мы не пытаемся отстаивать какую-либо точку зрения по поводу управления состоянием. Порой, простой виджет типа Inherited - все что вам нужно; а благодаря Provider виджеты такого типа становятся максимально простыми в использовании. К тому же, Provider также предоставляет множество полезных функций, с которыми мы познакомимся позже в статье.

Создайте файл app/state/app_state.dart и добавьте в него следующее:

import 'package:flutter/material.dart';
import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';
import 'models/models.dart';

import 'demo_users.dart';

/// State related to Stream-agram app.
///
/// Manages the connection and stores a references to the [StreamFeedClient]
/// and [StreamagramUser].
///
/// Provides various convenience methods.
class AppState extends ChangeNotifier {
  /// Create new [AppState].
  AppState({
    required StreamFeedClient client,
  }) : _client = client;

  late final StreamFeedClient _client;

  /// Stream Feed client.
  StreamFeedClient get client => _client;

  /// Stream Feed user - [StreamUser].
  StreamUser get user => _client.currentUser!;

  StreamagramUser? _streamagramUser;

  /// The extraData from [user], mapped to an [StreamagramUser] object.
  StreamagramUser? get streamagramUser => _streamagramUser;

  /// Connect to Stream Feed with one of the demo users, using a predefined,
  /// hardcoded token.
  ///
  /// THIS IS ONLY FOR DEMONSTRATIONS PURPOSES. USER TOKENS SHOULD NOT BE
  /// HARDCODED LIKE THIS.
  Future<bool> connect(DemoAppUser demoUser) async {
    final currentUser = await _client.setUser(
      User(id: demoUser.id),
      demoUser.token!,
      extraData: demoUser.data,
    );
   
    if (currentUser.data != null) {
      _streamagramUser = StreamagramUser.fromMap(currentUser.data!);
      notifyListeners();
      return true;
    } else {
      return false;
    }
  }
}

Данный класс не слишком сложный. Далее в статье, мы еще вернемся к нему и дополним код.

На данный момент он только управляет локальными пользователям и их данными, которые поступают в него. Класс под названием ChangeNotifier помогает с легкостью управлять состоянием во Flutter. Таким образом, вы храните изменяемые переменные в классе, который сможете обновить в будущем. Как только файл будет обновлен, вы сможете уведомить любых ваших слушателей (listeners) путем вызова метода notifyListeners.

Как вы можете увидеть в данном классе, переменная с названием _streamagramUser изначально является пустой и будет задана как только вы вызовете метод connect, требующий класс DemoAppUser.

Здесь важно обратить внимание на класс StreamFeedClient. Он включает множество методов, по которым он взаимодействует с API Stream Feeds. Одним из таких методов является метод setUser. Данный метод локально устанавливает текущих пользователей Stream Feeds для приложения и, далее, обрабатывает их. Если пользователь не существует, он создает его, а если пользователь все таки есть, он считывает его данные с бэкенда Stream Feeds.

Метод setUser требует наличия объекта User, который включает в себя:

  • уникальный идентификатор пользователя (его имя пользователя);

  • дополнительные данные о пользователе, которые в дальнейшем будут использованы на сервере (имя, URL адрес фото главного профиля, и так далее, в зависимости от ваших предпочтений);

  • токен аутентификации.

Данный метод входит в класс Future (асинхронный возвращаемый объект). Как только класс переходит в состояние завершен, вы обновляете объект _streamagramUser путем обращения к fromMap, который создает новый объект в каталоге Map. После этого обновляете всех слушателей путем вызова notifyListeners.

Обновите баррель-файл app/state/state.dart:

export 'demo_users.dart';
export 'app_state.dart'; // ADD THIS

Инициализация и Provider(ы)

Создайте новый файл. Назовите его app/stream_agram.dart и добавьте в него следующее:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:stream_agram/app/app.dart';
import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';

import '../components/login/login.dart';

/// {@template app}
/// Main entry point to the Stream-agram application.
/// {@endtemplate}
class StreamagramApp extends StatefulWidget {
  /// {@macro app}
  const StreamagramApp({
    Key? key,
    required this.appTheme,
  }) : super(key: key);

  /// App's theme data.
  final AppTheme appTheme;

  @override
  State<StreamagramApp> createState() => _StreamagramAppState();
}

class _StreamagramAppState extends State<StreamagramApp> {
  final _client = StreamFeedClient('YOUR KEY'); // TODO: Add API Key
  late final appState = AppState(client: _client);

  // Important to only initialize this once.
  // Unless you want to update the bloc state
  late final feedBloc = FeedBloc(client: _client);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider.value(
      value: appState,
      child: MaterialApp(
        title: 'Stream-agram',
        theme: widget.appTheme.lightTheme,
        darkTheme: widget.appTheme.darkTheme,
        themeMode: ThemeMode.dark,
        builder: (context, child) {
          // Stream Feeds provider to give access to [FeedBloc]
          // This class comes from Stream Feeds.
          return FeedProvider(
            bloc: feedBloc,
            child: child!,
          );
        },
        home: const LoginScreen(),
      ),
    );
  }
}

В данном файле, мы реализуем несколько важных моментов:

  • Инициализируем StreamFeedClient при помощи вашего ключа доступа (API Key);

  • Создаем класс AppState, который содержит StreamFeedClient;

  • Создаем FeedBloc (бизнес-логика для ваших приложений в Stream Feeds). Этот класс также можно найти в пакете Stream Feeds, и мы будем часто возвращаться к нему позже в данной статье;

  • Обертываем MaterialApp при помощи ChangeNotifierProvider открывая доступ к объекту AppState. Благодаря этому, все приложение сможет с легкостью получать доступ к состоянию.  Использование factory-метода .value важно, поскольку он предоставляет доступ к уже созданному объекту appState;

  • Присваиваем значение LoginScreen  аргументу home. Это понадобится нам при создании экрана входа в скором времени;

  • Используем аргумент Builder, чтобы обернуть наше приложение с помощью FeedProvider, что в свою очередь предоставляет доступ к объекту feedBloc для всего приложения. FeedProvider необходим виджетам Stream Feeds в целях получения доступа к FeedBloc независимо от того где они находятся в дереве виджетов.

Измените ваш баррель-файл app/app.dart следующим образом:

export 'state/state.dart';
export 'theme.dart';
export 'stream_agram.dart'; // ADD THIS

Расширение классов и Утилиты

Далее, создайте файл app/utils.dart, который будет содержать несколько полезных расширений, которые мы часто будем использовать позже в статье. Эти расширения сделают некоторые операции проще в нашем UI и помогут сократить количество кода, необходимое нам для выполнения часто повторяющихся операций.

Эти расширения позволят вам:

  • убирать и выводить на показ виджет Snackbar вместе с сообщением;

  • получать доступ к AppState из BuildContext.

Добавьте в файл следующее:

import 'package:flutter/material.dart';

import 'package:provider/provider.dart';

import 'state/app_state.dart';

/// Extension method on [BuildContext] to easily perform snackbar operations.
extension Snackbar on BuildContext {
  /// Removes the current active [SnackBar], and replaces it with a new snackbar
  /// with content of [message].
  void removeAndShowSnackbar(final String message) {
    ScaffoldMessenger.of(this).removeCurrentSnackBar();
    ScaffoldMessenger.of(this).showSnackBar(
      SnackBar(content: Text(message)),
    );
  }
}

/// Extension method on [BuildContext] to easily retrieve providers.
extension ProviderX on BuildContext {
  /// Returns the application [AppState].
  AppState get appState => read<AppState>();
}

Теперь наконец пришло время изменить наш баррель-файл app/app.dart в последний раз ???? (по крайней мере, пока что). Добавьте:

export 'state/state.dart';
export 'theme.dart';
export 'stream_agram.dart';
export 'utils.dart'; // ADD THIS

Создайте моковый экран входа для нашего Instagram*

Далее, вам необходимо будем создать экран входа - моковый экран, в котором вы сможете залогиниться под одним из ваших фейковых пользователей.

Создайте файл components/login/login_screen.d и добавьте в него следующее:

import 'package:flutter/material.dart';

import '../../app/app.dart';
import '../home/home.dart';

/// {@template login_screen}
/// Screen that presents an option of users to authenticate as.
/// {@endtemplate}
class LoginScreen extends StatefulWidget {
  /// {@macro login_screen}
  const LoginScreen({Key? key}) : super(key: key);

  @override
  _LoginScreenState createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
  @override
  Widget build(BuildContext context) {
    final size = MediaQuery.of(context).size;
    return Scaffold(
      appBar: AppBar(title: const Text('Demo users')),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 24.0),
        child: SizedBox(
          width: size.width,
          height: size.height,
          child: SingleChildScrollView(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const SizedBox(height: 42),
                for (final user in DemoAppUser.values)
                  Padding(
                    padding: const EdgeInsets.all(16.0),
                    child: ElevatedButton(
                      style: ButtonStyle(
                        backgroundColor: MaterialStateColor.resolveWith(
                            (states) => Colors.white),
                        padding: MaterialStateProperty.all(
                          const EdgeInsets.symmetric(horizontal: 4.0),
                        ),
                        shape: MaterialStateProperty.all(
                          RoundedRectangleBorder(
                            borderRadius: BorderRadius.circular(24.0),
                          ),
                        ),
                      ),
                      onPressed: () async {
                        context.removeAndShowSnackbar('Connecting user');

                        final success = await context.appState.connect(user);

                        if (success) {
                          context.removeAndShowSnackbar('User connected');

                          await Navigator.of(context).pushReplacement(
                            MaterialPageRoute(
                              builder: (_) => const HomeScreen(),
                            ),
                          );
                        } else {
                          context
                              .removeAndShowSnackbar('Could not connect user');
                        }
                      },
                      child: Padding(
                        padding: const EdgeInsets.symmetric(
                            vertical: 24.0, horizontal: 36.0),
                        child: SizedBox(
                          width: 200,
                          child: Text(
                            user.name!,
                            style: const TextStyle(
                              fontSize: 18,
                              color: Colors.blueGrey,
                            ),
                          ),
                        ),
                      ),
                    ),
                  )
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Вы можете остановиться на этом файле и поподробнее изучить его самостоятельно. Кратко, этот виджет выполняет следующее:

  • Выводит на показ кнопки, показывающие имя каждого пользователя. Если нажать на имя - будет предпринята попытка авторизации;

  • Вызывает метод connect в AppState и если ответ положительный (true), происходит переход на HomeScreen (главный экран); в противном случае Snackbar выдаст ошибку;

  • Показывает сообщения Snackbar о состоянии подключения.

Как только вы запустите приложение, ваш экран будет иметь следующий вид:

Не забудьте создать ваш баррель-файл для авторизации components/login/login.dart:

export 'login_screen.dart';

Создаем главный экран как в Instagram

Это будет основным экраном вашего приложения, откуда пользователи смогут переходить на другие экраны и вкладки.

Открывая настоящий Instagram*, мы имеем возможность переходить на другие вкладки приложения при помощи навигационной панели внизу экрана. Мы говорим про такие вкладки как:

  • Вкладка с хронологической лентой или домашняя вкладка (показывается по умолчанию при авторизации);

  • Вкладка поиска;

  • Вкладка Reels;

  • Вкладка «Магазин»;

  • Вкладка профиля пользователя.

В оригинальном приложении, у вас также есть возможность быстро перейти к наиболее часто используемым функциям с помощью верхней панели. Это такие функции как:

  • Создать новый пост; 

  • Просмотреть обновления;

  • Открыть сообщения.

На данном этапе мы создадим упрощенную версию приложения. Позже, мы изменим код, чтобы внести некоторые улучшения в работу главного экрана и его функциональность.

Для начала мы воспользуемся PageView и BottomNavigationBar для того, чтобы навигация в нашем приложении была похожа на настоящий Instagram и имела следующие вкладки:

  • Хронологическую ленту;

  • Вкладку поиска;

  • Вкладку профиля пользователя.

Пока что, все эти страницы будут содержать только текст-заглушку, который мы изменим позже.

Мы также создадим класс AppBar (это будет верхней панелью нашего приложения), который временно будет содержать только title (название). Позже мы добавим сюда больше элементов управления.

Создайте файл components/home/home_screen.dart и добавьте в него следующее:

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';

import '../../app/app.dart';

/// HomeScreen of the application.
///
/// Provides Navigation to various pages in the application and maintains their
/// state.
///
/// Default first page is [TimelinePage].
class HomeScreen extends StatefulWidget {
  /// Creates a new [HomeScreen]
  const HomeScreen({Key? key}) : super(key: key);

  /// List of pages available from the home screen.
  static const List<Widget> _homePages = <Widget>[
    Center(child: Text('TimelinePage')),
    Center(child: Text('SearchPage')),
    Center(child: Text('ProfilePage')),
  ];

  @override
  _HomeScreenState createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final PageController pageController = PageController();

  @override
  Widget build(BuildContext context) {
    final iconColor = Theme.of(context).appBarTheme.iconTheme!.color!;
    return Scaffold(
      appBar: AppBar(
        title:
            Text('Stream-agram', style: GoogleFonts.grandHotel(fontSize: 32)),
        elevation: 0,
        centerTitle: false,
      ),
      body: PageView(
        controller: pageController,
        physics: const NeverScrollableScrollPhysics(),
        children: HomeScreen._homePages,
      ),
      bottomNavigationBar: _StreamagramBottomNavBar(
        pageController: pageController,
      ),
    );
  }
}

class _StreamagramBottomNavBar extends StatefulWidget {
  const _StreamagramBottomNavBar({
    Key? key,
    required this.pageController,
  }) : super(key: key);

  final PageController pageController;

  @override
  State<_StreamagramBottomNavBar> createState() =>
      _StreamagramBottomNavBarState();
}

class _StreamagramBottomNavBarState extends State<_StreamagramBottomNavBar> {
  void _onNavigationItemTapped(int index) {
    widget.pageController.jumpToPage(index);
  }

  @override
  void initState() {
    super.initState();
    widget.pageController.addListener(() {
      setState(() {});
    });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        boxShadow: <BoxShadow>[
          BoxShadow(
            color: (Theme.of(context).brightness == Brightness.dark)
                ? AppColors.ligthGrey.withOpacity(0.5)
                : AppColors.faded.withOpacity(0.5),
            blurRadius: 1,
          ),
        ],
      ),
      child: BottomNavigationBar(
        onTap: _onNavigationItemTapped,
        showSelectedLabels: false,
        showUnselectedLabels: false,
        elevation: 0,
        iconSize: 28,
        currentIndex: widget.pageController.page?.toInt() ?? 0,
        items: const [
          BottomNavigationBarItem(
            icon: Icon(Icons.home_outlined),
            activeIcon: Icon(Icons.home),
            label: 'Home',
          ),
          BottomNavigationBarItem(
            icon: Icon(CupertinoIcons.search),
            activeIcon: Icon(
              Icons.search,
              size: 22,
            ),
            label: 'Search',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.person_outline),
            activeIcon: Icon(Icons.person),
            label: 'Person',
          )
        ],
      ),
    );
  }
}

Давайте подробнее разберем эту часть кода:

  • эта часть кода разделена на три отдельные части: AppBar, PageView и BottomNavigationBar;

  • список виджетов хранится в переменной _homePages. На данном этапе, они представляют собой лишь текст-заглушку, созданный виджетом Text;

  • в коде также присутствует PageController для перехода между страницами PageView. Он используется в  _StreamagramBottomNavBar. Здесь также есть слушатель, добавленный к контроллеру, чтобы обновлять UI, когда тот меняется;

  • значение для физики PageView устанавливается как  NeverScrollableScrollPhysics. Благодаря этому, единственным способом перемещаться между вкладками является нажатие на одну из них в нижней навигационной панели;

  • здесь, в классе AppBar, мы также используем шрифт grandHotel из пакета GoogleFonts для того, чтобы шрифт названия нашего приложения был идентичен шрифту в настоящем Instagram. Это название отображается в левой верхней части экрана.

Теперь если вы запустите приложение, то главный экран будет выглядеть следующим образом:

И вы также должны иметь возможность перемещаться между разными страницами, благодаря PageView.

Точка входа (main.dart)

Последний штрих. Удалите все из main.dart и вставьте следующее:

import 'package:flutter/material.dart';

import 'app/app.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  final theme = AppTheme();
  runApp(StreamagramApp(appTheme: theme));
}

Теперь, если вы все сделали правильно, у вас должен быть экран входа, где вы можете выбрать пользователя и перейти на главный экран после авторизации. Если что-то пошло не так, проверьте правильно ли вы указали токены пользователей, их идентификаторы или ключ API.

Супер ????. Теперь, когда мы завершили работу над базовыми блоками, мы можем перейти к более сложным.

Создаем аватар при помощи виджета Avatar

По ходу создания вашего инстаграм клона, вы будете использовать несколько глобальных компонентов - виджетов. Сейчас, давайте добавим один из них. Это будет виджет Avatar, который показывает главное фото профиля (в случае если оно установлено), или просто инициалы пользователя (в случае когда фото отсутствует).

Создайте components/app_widgets/avatars.dart  и добавьте следующий код:

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';

import '../../app/app.dart';

/// An avatar that displays a user's profile picture.
///
/// Supports different sizes:
/// - `Avatar.tiny`
/// - `Avatar.small`
/// - `Avatar.medium`
/// - `Avatar.big`
/// - `Avatar.huge`
class Avatar extends StatelessWidget {
  /// Creates a tiny avatar.
  const Avatar.tiny({
    Key? key,
    required this.streamagramUser,
  })  : _avatarSize = _tinyAvatarSize,
        _coloredCircle = _tinyColoredCircle,
        hasNewStory = false,
        fontSize = 12,
        isThumbnail = true,
        super(key: key);

  /// Creates a small avatar.
  const Avatar.small({
    Key? key,
    required this.streamagramUser,
  })  : _avatarSize = _smallAvatarSize,
        _coloredCircle = _smallColoredCircle,
        hasNewStory = false,
        fontSize = 14,
        isThumbnail = true,
        super(key: key);

  /// Creates a medium avatar.
  const Avatar.medium({
    Key? key,
    this.hasNewStory = false,
    required this.streamagramUser,
  })  : _avatarSize = _mediumAvatarSize,
        _coloredCircle = _mediumColoredCircle,
        fontSize = 20,
        isThumbnail = true,
        super(key: key);

  /// Creates a big avatar.
  const Avatar.big({
    Key? key,
    this.hasNewStory = false,
    required this.streamagramUser,
  })  : _avatarSize = _largeAvatarSize,
        _coloredCircle = _largeColoredCircle,
        fontSize = 26,
        isThumbnail = false,
        super(key: key);

  /// Creates a huge avatar.
  const Avatar.huge({
    Key? key,
    this.hasNewStory = false,
    required this.streamagramUser,
  })  : _avatarSize = _hugeAvatarSize,
        _coloredCircle = _hugeColoredCircle,
        fontSize = 30,
        isThumbnail = false,
        super(key: key);

  /// Indicates if the user has a new story. If yes, their avatar is surrounded
  /// with an indicator.
  final bool hasNewStory;

  /// The user data to show for the avatar.
  final StreamagramUser streamagramUser;

  /// Text size of the user's initials when there is no profile photo.
  final double fontSize;

  final double _avatarSize;
  final double _coloredCircle;

  // Small avatar configuration
  static const _tinyAvatarSize = 22.0;
  static const _tinyPaddedCircle = _tinyAvatarSize + 2;
  static const _tinyColoredCircle = _tinyPaddedCircle * 2 + 4;

  // Small avatar configuration
  static const _smallAvatarSize = 30.0;
  static const _smallPaddedCircle = _smallAvatarSize + 2;
  static const _smallColoredCircle = _smallPaddedCircle * 2 + 4;

  // Medium avatar configuration
  static const _mediumAvatarSize = 40.0;
  static const _mediumPaddedCircle = _mediumAvatarSize + 2;
  static const _mediumColoredCircle = _mediumPaddedCircle * 2 + 4;

  // Large avatar configuration
  static const _largeAvatarSize = 90.0;
  static const _largPaddedCircle = _largeAvatarSize + 2;
  static const _largeColoredCircle = _largPaddedCircle * 2 + 4;

  // Huge avatar configuration
  static const _hugeAvatarSize = 120.0;
  static const _hugePaddedCircle = _hugeAvatarSize + 2;
  static const _hugeColoredCircle = _hugePaddedCircle * 2 + 4;

  /// Whether this avatar uses a thumbnail as an image (low quality).
  final bool isThumbnail;

  @override
  Widget build(BuildContext context) {
    final picture = _CircularProfilePicture(
      size: _avatarSize,
      userData: streamagramUser,
      fontSize: fontSize,
      isThumbnail: isThumbnail,
    );

    if (!hasNewStory) {
      return picture;
    }
    return Container(
      width: _coloredCircle,
      height: _coloredCircle,
      decoration: const BoxDecoration(
        color: Colors.red,
        shape: BoxShape.circle,
      ),
      child: Center(child: picture),
    );
  }
}

class _CircularProfilePicture extends StatelessWidget {
  const _CircularProfilePicture({
    Key? key,
    required this.size,
    required this.userData,
    required this.fontSize,
    this.isThumbnail = false,
  }) : super(key: key);

  final StreamagramUser userData;

  final double size;
  final double fontSize;

  final bool isThumbnail;

  @override
  Widget build(BuildContext context) {
    final profilePhoto = isThumbnail
        ? userData.profilePhotoThumbnail
        : userData.profilePhotoResized;

    return (profilePhoto == null)
        ? Container(
            width: size,
            height: size,
            decoration: const BoxDecoration(
              color: AppColors.secondary,
              shape: BoxShape.circle,
            ),
            child: Center(
              child: Text(
                '${userData.firstName[0]}${userData.lastName[0]}',
                style: TextStyle(fontSize: fontSize),
              ),
            ),
          )
        : SizedBox(
            width: size,
            height: size,
            child: CachedNetworkImage(
              imageUrl: profilePhoto,
              fit: BoxFit.contain,
              imageBuilder: (context, imageProvider) => Container(
                width: size,
                height: size,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  image:
                      DecorationImage(image: imageProvider, fit: BoxFit.cover),
                ),
              ),
            ),
          );
  }
}

Вы можете самостоятельно изучить этот виджет. Важным моментом здесь является то, что он поддерживает несколько разных конструкторов, названия которых соответствуют их размерах (к примеру - tiny, large, huge и т.д.). Данный виджет будет использоваться всякий раз, когда вы будете выводить на показ главное фото пользователя (его аватар). Данный виджет  также использует пакет cached_network_image package для кэширования изображений.

Создайте баррель-файл components/app_widgets/app_widgets.dart и экспортируйте следующий файл:

export 'avatars.dart';

Создаем вкладку профиля

Как минимум, вкладка профиля для нашего приложения должна содержать следующее:

  • главное фото и информацию о пользователе;

  • информацию о подписках и подписчиках;

  • функционал позволяющий легко редактировать профиль;

  • ленту постов, созданных пользователем;

  • возможность публиковать новые посты.

Далее, мы детально разберем каждый из этих пунктов.

Создайте страницу профиля

Создайте файл components/profile/profile_page.dart и добавьте в него:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';

import '../../app/app.dart';
import '../app_widgets/app_widgets.dart';

/// {@template profile_page}
/// User profile page. List of user created posts.
/// {@endtemplate}
class ProfilePage extends StatelessWidget {
  /// {@macro profile_page}
  const ProfilePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return FlatFeedCore(
      feedGroup: 'user',
      loadingBuilder: (context) =>
          const Center(child: CircularProgressIndicator()),
      errorBuilder: (context, error) => const Center(
        child: Text('Error loading profile'),
      ),
      emptyBuilder: (context) => const CustomScrollView(
        slivers: [
          SliverToBoxAdapter(
            child: _ProfileHeader(
              numberOfPosts: 0,
            ),
          ),
          SliverToBoxAdapter(
            child: _EditProfileButton(),
          ),
          SliverToBoxAdapter(
            child: SizedBox(height: 24),
          ),
          SliverFillRemaining(child: _NoPostsMessage())
        ],
      ),
      feedBuilder: (context, activities) {
        return Text('TODO'); // TODO show activities
      },
    );
  }
}

class _EditProfileButton extends StatelessWidget {
  const _EditProfileButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8.0),
      child: OutlinedButton(
        onPressed: () {
          // TODO handle onPressed
        },
        child: const Text('Edit Profile'),
      ),
    );
  }
}

class _ProfileHeader extends StatelessWidget {
  const _ProfileHeader({
    Key? key,
    required this.numberOfPosts,
  }) : super(key: key);

  final int numberOfPosts;

  static const _statitisticsPadding =
      EdgeInsets.symmetric(horizontal: 12, vertical: 8.0);

  @override
  Widget build(BuildContext context) {
    final feedState = context.watch<AppState>();
    final streamagramUser = feedState.streamagramUser;
    if (streamagramUser == null) return const SizedBox.shrink();
    return Column(
      children: [
        Row(
          children: [
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: Avatar.big(
                streamagramUser: streamagramUser,
              ),
            ),
            const Spacer(),
            Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Padding(
                  padding: _statitisticsPadding,
                  child: Column(
                    children: [
                      Text(
                        '$numberOfPosts',
                        style: AppTextStyle.textStyleBold,
                      ),
                      const Text(
                        'Posts',
                        style: AppTextStyle.textStyleLight,
                      ),
                    ],
                  ),
                ),
                Padding(
                  padding: _statitisticsPadding,
                  child: Column(
                    children: [
                      Text(
                        '${FeedProvider.of(context).bloc.currentUser?.followersCount ?? 0}',
                        style: AppTextStyle.textStyleBold,
                      ),
                      const Text(
                        'Followers',
                        style: AppTextStyle.textStyleLight,
                      ),
                    ],
                  ),
                ),
                Padding(
                  padding: _statitisticsPadding,
                  child: Column(
                    children: [
                      Text(
                        '${FeedProvider.of(context).bloc.currentUser?.followingCount ?? 0}',
                        style: AppTextStyle.textStyleBold,
                      ),
                      const Text(
                        'Following',
                        style: AppTextStyle.textStyleLight,
                      ),
                    ],
                  ),
                ),
              ],
            ),
          ],
        ),
        Align(
          alignment: Alignment.centerLeft,
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(streamagramUser.fullName,
                style: AppTextStyle.textStyleBoldMedium),
          ),
        ),
      ],
    );
  }
}

class _NoPostsMessage extends StatelessWidget {
  const _NoPostsMessage({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        const Text('This is too empty'),
        const SizedBox(height: 12),
        ElevatedButton(
          onPressed: () {
            // TODO handle onPressed
          },
          child: const Text('Add a post'),
        )
      ],
    );
  }
}

В этом файле мы наконец используем некоторые виджеты из пакета stream_chat_flutter_core, в целях отображения действий в ленте. Вы можете изучить эту часть кода самостоятельно в удобном для вас темпе. Сейчас давайте обсудим наиболее важные его части:

  • FlatFeedCore требует, чтобы вы указали feedGroup. Для нашей страницы профиля нам нужно указать здесь «user». Не забывайте, что это именно то значение, что мы указали в самом начале статьи;

  • FlatFeedCore имеет четыре билдера, которые нужны нам для обработки различных состояний (loadingBuilder, errorBuilder, emptyBuilder, и feedBuilder);

  • emptyBuilder возвращает CustomScrollView, который отображает такие виджеты как: _ProfileHeader, _EditProfileButton, и _NoPostsMessage;

  • feedBuilder вы обновите позже;

  • виджет _ProfileHeader использует Provider для того, чтобы следить за состоянием AppState;

  • в этой части кода вы также можете увидеть несколько TODO комментариев, требующих обработки, но к ним мы вернемся чуть позже.

Благодаря виджету FlatFeedCore извлечение данных об отдельных постах (действий) из вашей ленты становиться проще, и вы можете отображать их как пожелаете. Существует ряд аргументов, которые вы можете присвоить этому виджету в целях улучшения пагинации и фильтрования содержимого в вашей ленте. Однако пока что, мы не будем глубоко углубляться в это. Вы можете изучить этот вопрос после прочтения данной статьи.

Создайте баррель-файл components/profile/profile.dart и добавьте туда следующее:

export 'profile_page.dart';

Затем вернитесь обратно к components/home/home_screen.dart и измените переменную _homePages, чтобы она показывала ProfilePage. Это должно выглядеть следующим образом:

...
import '../profile/profile.dart'; // ADD IMPORT
...
/// List of pages available from the home screen.
  static const List<Widget> _homePages = <Widget>[
    Center(child: Text('TimelinePage')),
    Center(child: Text('SearchPage')),
    ProfilePage(), // MODIFY THIS
  ];

Если вы сейчас запустите приложение и откроете вкладку профиля, то ее вид должен быть похожим на это:

Создайте экран «Редактировать профиль»

Экран «Редактировать профиль» (Edit Profile) отображает информацию о пользователе (например его имя) и позволяет пользователям изменять главное фото профиля. Конечно, вы можете и расширить функционал этого экрана, добавив в него ряд других функций если пожелаете, но на данный момент мы хотим просто отобразить главное фото пользователя, его обычное имя и пользовательское.

Обновляем AppState

Перед тем как перейти к созданию UI, вам нужно обновить класс AppState.

Откройте app/state/app_state.dart и измените его как указано ниже:

class AppState extends ChangeNotifier {

...

	var isUploadingProfilePicture = false;

...

	/// Uploads a new profile picture from the given [filePath].
  ///
  /// This will call [notifyListeners] and update the local [_streamagramUser] state.
  Future<void> updateProfilePhoto(String filePath) async {
    // Upload the original image
    isUploadingProfilePicture = true;
    notifyListeners();

    final imageUrl = await client.images.upload(AttachmentFile(path: filePath));
    if (imageUrl == null) {
      debugPrint('Could not upload the image. Not setting profile picture');
      isUploadingProfilePicture = false;
      notifyListeners();
      return;
    }
    // Get resized images using the Stream Feed client.
    final results = await Future.wait([
      client.images.getResized(
        imageUrl,
        const Resize(500, 500),
      ),
      client.images.getResized(
        imageUrl,
        const Resize(50, 50),
      )
    ]);

    // Update the current user data state.
    _streamagramUser = _streamagramUser?.copyWith(
      profilePhoto: imageUrl,
      profilePhotoResized: results[0],
      profilePhotoThumbnail: results[1],
    );

    isUploadingProfilePicture = false;

    // Upload the new user data for the current user.
    if (_streamagramUser != null) {
      await client.currentUser!.update(_streamagramUser!.toMap());
    }

    notifyListeners();
  }

Код выше создает переменную состояния isUploadingProfilePicture, для которой изначально задано значение false.

Метод updateProfilePhoto выполняет следующее:

  1. Считывает путь к файлу изображения;

  2. Меняет значение переменной isUploadingProfilePicture на true. Вы сможете увидеть эту переменную в вашем UI;

  3. Загружает файл изображения на Stream CDN, при помощи StreamFeedClient и AttachmentFile;

  4. Используя метод getResized, создает уменьшенную версию изображения, которую будет использовать приложение, когда для фото профиля выбран размер tiny или small;

  5. Добавляет новый URL вашего фото в _streamagramUser;

  6. Снова меняет значение переменной isUploadingProfilePicture на false;

  7. Обновляет данные пользователя в базе данных вызовом метода update для currentUser.

Этот код также вызывает метод notifyListeners несколько раз для того, чтобы обновить UI.

Создаем функцию выбора изображения как в Instagram*

Для того, внедрить в наш клон функцию, позволяющую выбирать изображение из галереи нашего смартфона (например для публикации или в качестве главного фото профиля), мы будем использовать пакет image_picker.

По ссылке вы можете найти рекомендации по установке. В зависимости от операционной системы (iOS или Android), шаги могут различаться.

Экран изменения профиля (UI)

Создайте файл components/profile/edit_profile_screen.dart и добавьте следующее:

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';

import '../../app/app.dart';
import '../app_widgets/app_widgets.dart';

/// {@template edit_profile_page}
/// Screen to edit a user's profile info.
/// {@endtemplate}
class EditProfileScreen extends StatelessWidget {
  /// {@macro edit_profile_page}
  const EditProfileScreen({
    Key? key,
  }) : super(key: key);

  /// Custom route to this screen. Animates from the bottom up.
  static Route get route => PageRouteBuilder(
        pageBuilder: (context, animation, secondaryAnimation) =>
            const EditProfileScreen(),
        transitionsBuilder: (context, animation, secondaryAnimation, child) {
          final tween = Tween(begin: const Offset(0.0, 1.0), end: Offset.zero)
              .chain(CurveTween(curve: Curves.easeOutQuint));
          final offsetAnimation = animation.drive(tween);
          return SlideTransition(
            position: offsetAnimation,
            child: child,
          );
        },
      );

  @override
  Widget build(BuildContext context) {
    final streamagramUser = context
        .select<AppState, StreamagramUser?>((value) => value.streamagramUser);
    if (streamagramUser == null) {
      return const Scaffold(
        body: Center(
          child: Text('You should not see this.\nUser data is empty.'),
        ),
      );
    }
    return Scaffold(
      appBar: AppBar(
        leading: TextButton(
          onPressed: () {
            Navigator.of(context).pop();
          },
          child: Text(
            'Cancel',
            style: (Theme.of(context).brightness == Brightness.dark)
                ? const TextStyle(color: AppColors.light)
                : const TextStyle(color: AppColors.dark),
          ),
        ),
        leadingWidth: 80,
        title: const Text(
          ' Edit profile',
          style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
        ),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
            },
            child: const Text('Done'),
          ),
        ],
      ),
      body: ListView(
        children: [
          const _ChangeProfilePictureButton(),
          const Divider(
            color: Colors.grey,
          ),
          Padding(
            padding: const EdgeInsets.all(8),
            child: Row(
              children: [
                const SizedBox(
                  width: 100,
                  child: Text(
                    'Name',
                    style: AppTextStyle.textStyleBoldMedium,
                  ),
                ),
                Text(
                  '${streamagramUser.fullName} ',
                  style: AppTextStyle.textStyleBoldMedium,
                ),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8),
            child: Row(
              children: [
                const SizedBox(
                  width: 100,
                  child: Text(
                    'Username',
                    style: AppTextStyle.textStyleBoldMedium,
                  ),
                ),
                Text(
                  '${context.appState.user.id} ',
                  style: AppTextStyle.textStyleBoldMedium,
                ),
              ],
            ),
          ),
          const Divider(color: Colors.grey),
        ],
      ),
    );
  }
}

class _ChangeProfilePictureButton extends StatefulWidget {
  const _ChangeProfilePictureButton({
    Key? key,
  }) : super(key: key);

  @override
  __ChangeProfilePictureButtonState createState() =>
      __ChangeProfilePictureButtonState();
}

class __ChangeProfilePictureButtonState
    extends State<_ChangeProfilePictureButton> {
  final _picker = ImagePicker();

  Future<void> _changePicture() async {
    if (context.appState.isUploadingProfilePicture == true) {
      return;
    }

    final pickedFile = await _picker.pickImage(
      source: ImageSource.gallery,
      maxWidth: 800,
      maxHeight: 800,
      imageQuality: 70,
    );
    if (pickedFile != null) {
      await context.appState.updateProfilePhoto(pickedFile.path);
    } else {
      context.removeAndShowSnackbar('No picture selected');
    }
  }

  @override
  Widget build(BuildContext context) {
    final streamagramUser = context
        .select<AppState, StreamagramUser>((value) => value.streamagramUser!);
    final isUploadingProfilePicture = context
        .select<AppState, bool>((value) => value.isUploadingProfilePicture);
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          SizedBox(
            height: 150,
            child: Center(
              child: isUploadingProfilePicture
                  ? const CircularProgressIndicator()
                  : GestureDetector(
                      onTap: _changePicture,
                      child: Avatar.huge(streamagramUser: streamagramUser),
                    ),
            ),
          ),
          GestureDetector(
            onTap: _changePicture,
            child: const Text('Change Profile Photo',
                style: AppTextStyle.textStyleAction),
          ),
        ],
      ),
    );
  }
}

Давайте поподробнее остановимся на этой части кода:

  • здесь мы используем собственный PageRouteBuilder, который использует SlideTransition. Это позволяет странице плавно сворачиваться после того как вы её закрываете, подобно тому как это сделано в Instagram;

  • также используем различные UI-виджеты для того, чтобы отображать информацию о пользователе;

  • метод _changePicture мы уже создали ранее в статье. Этот метод использует пакет image_picker для выбора изображения из галереи и последующей загрузки его при помощи  метода updateProfilePhoto;

  • здесь мы также, указываем максимальный размер и разрешение загружаемого изображения с помощью maxWidth, maxHeight, и imageQuality.

Теперь ваш экран «Редактировать профиль» должен принять следующий вид:

Измените баррель-файл components/profile/profile.dart  следующим образом:

export 'edit_profile_screen.dart'; // ADD THIS
export 'profile_page.dart';

Для файла components/profile/profile_page.dart  измените TODO комментарий в виджете _EditProfileButton

...

import 'package:stream_agram/components/profile/edit_profile_screen.dart';

...

class _EditProfileButton extends StatelessWidget {
  const _EditProfileButton({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8.0),
      child: OutlinedButton(
        onPressed: () {
          Navigator.of(context).push(EditProfileScreen.route); // ADD THIS
        },
        child: const Text('Edit Profile'),
      ),
    );
  }
}

...

Ура!!! ????

Если вы правильно выполнили все шаги ,описанные выше, теперь на странице вашего профиля вы должны иметь возможность поставить реальное изображение на аватар вместо заглушки в виде инициалов, как на примере ниже:

На сегодня все. Постараюсь последнюю третью часть перевода выложить в ближайшее время.

Instagram* - запрещенная в России социальная сеть.

Также предлагаю подписаться на мой канал и на канал моего проекта:
Ссылка на канал проекта в телеграм: 
https://t.me/dom24x7
Ссылка на мой канал: 
https://t.me/evgaj

Комментарии (1)


  1. hardtop
    19.04.2023 13:58

    Основательно как! Спасибо!