Привет, Хабр! Меня зовут Андрей, я Data Engineer. В свободное время я решил написать свой «велосипед» — небольшую соцсеть для поиска единомышленников по нишевым интересам.

Спойлер: Google Sign-In на Flutter Web — это отдельный вид боли, но оно того стоило.

Ниже — полная история проекта Syncory, все «грабли», с которыми я столкнулся, и 100% открытый исходный код.

Проблема: «Как найти напарника для Warhammer?»

Всё началось с простого вопроса: «Как быстро найти людей по очень нишевым интересам?»

Мне, как инженеру, не хватало платформы, где можно отфильтровать людей не по друзьям или геолокации, а по конкретным категориям интересов: flutter, data-analysis, gamedev или warhammer-40k.

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

Так родился Syncory (ранее Synq) — pet-project, который быстро вырос в нечто большее, чем просто учебное приложение.

Выбор стека: простота превыше всего

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

Бэкенд: 100% Serverless

Firebase — очевидный выбор:

  • Firebase Auth — готовая аутентификация из коробки

  • Firestore — NoSQL база данных в реальном времени

  • Бесплатный tier для старта проекта

  • Нулевая настройка инфраструктуры

Фронтенд: Flutter Web

Мне нужно было веб-приложение, доступное с любого ПК. Flutter Web подошёл идеально:

  • Быстрая разработка UI с использованием виджетов

  • Material Design 3 «из коробки»

  • Единая кодовая база для веба и мобильных платформ (на будущее)

  • Активное комьюнити и хорошая документация

Да, я знаю про React/Vue, но как человеку, который пишет на Python и SQL, синтаксис Dart показался мне более понятным и близким.

Что под капотом: ключевые фичи

Я хотел не просто «фид с постами», а полноценное приложение с продуманным UX. Вот что я реализовал:

1. Анимированная страница входа (wow-эффект)

Первое впечатление решает всё. Я отказался от скучного статичного лейаута с двумя колонками.

Решение: полноэкранный интерактивный фон с анимированными элементами.

Как это работает:

// Слой 1: Отслеживание позиции мыши
MouseRegion(
  onHover: (event) {
    setState(() {
      _mousePosition = event.position;
    });
  },
  child: Stack(
    children: [
      // Слой 2: Анимированные чипы
      ..._buildAnimatedChips(),
      
      // Слой 3: Форма входа
      _buildLoginCard(),
    ],
  ),
)

Фишка в _AnimatedChip: это StatefulWidget, который получает mousePosition и с помощью AnimatedContainer и Matrix4.translationValues плавно «убегает» от курсора.

Эффект минимальный, но он мгновенно вовлекает пользователя и создаёт ощущение «живого» приложения.

class _AnimatedChip extends StatefulWidget {
  final Offset mousePosition;
  final Offset initialPosition;
  final String label;
  // ...
}

Результат: пользователь сразу видит, что это не очередной скучный шаблон, а продуманный интерфейс.

2. Админка «по-взрослому» (роли и гейты)

С самого начала я решил, что мне нужен контроль над контентом и возможность модерации.

Как реализовано:

Роль в Firestore:

{
  "users": {
    "user_id": {
      "email": "admin@example.com",
      "role": "admin",
      "isDisabled": false
    }
  }
}

Гейт в приложении:

// main_scaffold.dart
bool _isAdmin = false;

Future<void> _checkAdminStatus() async {
  final user = FirebaseAuth.instance.currentUser;
  if (user == null) return;
  
  final doc = await FirebaseFirestore.instance
      .collection('users')
      .doc(user.uid)
      .get();
      
  setState(() {
    _isAdmin = doc.data()?['role'] == 'admin';
  });
}

Секретные кнопки в интерфейсе:

if (_isAdmin) ...[
  IconButton(
    icon: Icon(Icons.admin_panel_settings),
    onPressed: () => _navigateToAdminPanel(),
  ),
]

Система банов:

  • Админ устанавливает флаг isDisabled: true в документе пользователя

  • При следующем входе main.dart проверяет этот флаг

  • Если true — принудительный signOut() и редирект на LoginPage с сообщением о бане

Такой подход позволяет не удалять данные пользователя, а временно блокировать доступ.

3. Приватные комментарии (фича для вовлечения)

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

Логика:

На странице создания поста автор выбирает политику видимости комментариев:

SegmentedButton<String>(
  segments: [
    ButtonSegment(value: 'all', label: Text('Все')),
    ButtonSegment(value: 'approvedOnly', label: Text('Только одобренные')),
  ],
  selected: {_commentVisibility},
  onSelectionChanged: (Set<String> newSelection) {
    setState(() {
      _commentVisibility = newSelection.first;
    });
  },
)

На странице поста реализован гейт доступа:

class _CommentsAccessGate extends StatelessWidget {
  Widget build(BuildContext context) {
    final commentPolicy = post['commentVisibility'] ?? 'all';
    
    // Автор видит всё
    if (post['userId'] == currentUserId) {
      return _buildCommentsSection(showInput: true);
    }
    
    // Публичные комментарии
    if (commentPolicy == 'all') {
      return _buildCommentsSection(showInput: true);
    }
    
    // Приватные: проверяем статус заявки
    return StreamBuilder(
      stream: FirebaseFirestore.instance
          .collection('posts/${post.id}/applicants')
          .doc(currentUserId)
          .snapshots(),
      builder: (context, snapshot) {
        if (snapshot.data?.data()?['status'] == 'approved') {
          return _buildCommentsSection(showInput: true);
        }
        return _buildAccessDeniedMessage();
      },
    );
  }
}

Результат: пользователи более активно подают заявки, чтобы получить доступ к обсуждению.

4. Три круга ада: Google Sign-In на Flutter Web

А вот здесь началось самое интересное. Я наивно думал, что Google Auth — это просто await GoogleSignIn.instance.signIn().

Как же я ошибался.

Я потратил 3 дня на отладку, прежде чем всё заработало. Вот что нужно знать, если вы делаете Google Auth на Flutter Web:

Круг ада №1: OAuth Client ID vs Firebase

Проблема: Firebase Console даёт вам Web API Key, но для Google Sign-In на вебе этого недостаточно.

Решение: идём в Google Cloud Console → APIs & Services → Credentials и создаём OAuth 2.0 Client ID типа «Web application».

Круг ада №2: Redirect URI Mismatch

Проблема: получаете Error 400: redirect_uri_mismatch, хотя вроде всё настроили.

Решение: в настройках OAuth Client ID нужно добавить ваш URL в ДВА списка:

  1. Authorized JavaScript origins: https://your-app.web.app

  2. Authorized redirect URIs: https://your-app.web.app/__/auth/handler

Если пропустите второй — получите ошибку.

Круг ада №3: People API

Проблема: Error 403: PERMISSION_DENIED при попытке входа.

Решение: в Google Cloud Console → APIs & Services → Library ищем People API и включаем её для проекта.

Круг ада №4: Конфигурация в коде

После всех настроек в консолях, нужно правильно сконфигурировать Flutter:

В web/index.html:

<head>
  <meta name="google-signin-client_id" 
        content="YOUR-CLIENT-ID.apps.googleusercontent.com">
</head>

В login_page.dart:

final GoogleSignIn _googleSignIn = GoogleSignIn(
  scopes: [
    'email',
    'openid', // ОБЯЗАТЕЛЕН для получения idToken!
  ],
);

Future<void> _signInWithGoogle() async {
  try {
    final GoogleSignInAccount? googleUser = await _googleSignIn.signIn();
    if (googleUser == null) return; // Пользователь отменил вход
    
    final GoogleSignInAuthentication googleAuth = 
        await googleUser.authentication;
    
    final OAuthCredential credential = GoogleAuthProvider.credential(
      idToken: googleAuth.idToken,
      // accessToken будет null на вебе!
    );
    
    await FirebaseAuth.instance.signInWithCredential(credential);
  } catch (e) {
    print('Error: $e');
  }
}

Важно: если видите призрачные ошибки типа Couldn't find constructor 'GoogleSignIn', хотя код правильный — выполните:

flutter clean
flutter pub get

И в VS Code: Ctrl+Shift+PReload Window.

Работа с Firestore: индексы и оптимизация

Firestore требует создания индексов для каждого сложного запроса. При первом выполнении нового запроса вы получите ошибку failed-precondition с ссылкой на создание индекса.

Пример:

// Такой запрос требует составного индекса
FirebaseFirestore.instance
    .collection('posts')
    .where('categories', arrayContains: 'flutter')
    .orderBy('createdAt', descending: true)
    .limit(20);

Решение: просто переходите по ссылке из ошибки в консоли, Firebase автоматически создаст нужный индекс. Через 1-2 минуты запрос заработает.

Что в итоге?

Получилось быстрое веб-приложение с продуманным UX и полным контролем над данными.

Технические метрики:

  • Время загрузки главной страницы: ~1.5 сек

  • Полностью serverless архитектура

  • Затраты на хостинг: $0 (в рамках бесплатного тира Firebase)

  • Время разработки: ~6 часов активного вайбкодинга

Благодарности

Отдельное огромное спасибо моему коллеге Aibol Nazenov (Айболу Назенову). Он был моим партнёром по брейнштормингу и принимал ключевое участие в разработке концепции Syncory. Без его фидбэка и критики проект не был бы таким, какой он есть сейчас.

Попробуйте сами (и посмотрите код)

Я выложил весь код на GitHub под лицензией GNU GPLv3.

? Живое демо: syncory-flutter-app.web.app

? GitHub: github.com/ungernthabaron/syncory-flutter-app

Проект полностью открыт — можете использовать его как основу для своих идей или просто изучить реализацию.

Вопрос к сообществу

А с какими «неочевидными» граблями Flutter Web или Firebase сталкивались вы? Особенно интересно услышать про проблемы с производительностью или специфичные кейсы интеграции.

Делитесь опытом в комментариях!


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


  1. DanielKross
    16.11.2025 12:36

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