DEV Community

Cover image for I had 1000+ unread user reports in my live Flutter app — here is how I fixed the whole system
Wasey Jamal
Wasey Jamal

Posted on

I had 1000+ unread user reports in my live Flutter app — here is how I fixed the whole system

DramaHub has been live on the Google Play Store since March 2026. 7,000+ downloads. 3,000+ daily active users. Built solo. ₹0/month infrastructure cost.

Today I found out I had been completely ignoring my users for 2 months.


The original setup

Both the "Suggest a Drama" and "Report a Problem" screens were Google Forms embedded in a Flutter WebView. Simple, fast to build, works on day one.

_controller = WebViewController()
  ..setJavaScriptMode(JavaScriptMode.unrestricted)
  ..setBackgroundColor(Colors.white)
  ..loadRequest(Uri.parse(_formUrl));
Enter fullscreen mode Exit fullscreen mode

Responses went to a Google Sheet I never opened. No notification. No push alert. Nothing.

I had 1000+ drama suggestions and 1000+ problem reports sitting there completely unread.

Users were putting in effort to give me feedback. I was not receiving any of it.


The problems with the WebView approach

1. White background in a dark app
Google Forms load with a white background. My entire app is dark themed — #0D0D0D background. Every time a user opened these screens they got a jarring white flash. Unprofessional.

2. Zero notification
Responses go to a Google Sheet. Unless you manually open the sheet, you never know a submission happened.

3. No communication path
Users had no way to leave contact info. I had no way to follow up. Completely one-way.

4. No offline handling
If the form failed to load, users saw a blank white screen with no error message.


What I built instead

I replaced both screens with native Flutter forms — dark themed, matching the rest of the app exactly.

On submit, the form data is sent directly to a Telegram bot via the Telegram Bot API. The bot posts a formatted message to my private Telegram group instantly.

The Telegram message format

🚨 Problem Report                    🕐 23 May 2026, 14:32
─────────────────────────────
⚠️ Type: Video not playing
🎥 Drama / Episode: Arafta Episode 56
📝 Description: Video not loading on wifi at all
─────────────────────────────
👤 Contact: @username
📱 App Version: 1.0.7
🌍 Country: IN
Enter fullscreen mode Exit fullscreen mode

Full detail. Instant. Every time.

The TelegramService

class TelegramService {
  TelegramService._();
  static final TelegramService instance = TelegramService._();

  String get _botToken => AppConfigService.instance.config.telegramBotToken;
  String get _chatId => AppConfigService.instance.config.telegramChatId;

  Future<bool> sendMessage(String message) async {
    final uri = Uri.parse(
      'https://api.telegram.org/bot$_botToken/sendMessage',
    );
    final response = await http.post(
      uri,
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'chat_id': _chatId,
        'text': message,
        'parse_mode': 'HTML',
      }),
    ).timeout(const Duration(seconds: 10));

    return response.statusCode == 200;
  }
}
Enter fullscreen mode Exit fullscreen mode

Key design decisions:

  • Bot token and chat ID are never hardcoded — fetched from app_config.json via Cloudflare Worker at runtime
  • Admin can rotate the token anytime from the config — zero app update needed
  • Uses existing http package — no new dependency

Silent data attached to every submission

The user never sees these — they just appear in the Telegram message:

// App version — reads from pubspec.yaml automatically
final info = await PackageInfo.fromPlatform();
final appVersion = info.version; // "1.0.7"

// Country — from device locale, no permission needed
final locale = WidgetsBinding.instance.platformDispatcher.locale;
final country = locale.countryCode ?? 'Unknown'; // "IN"
Enter fullscreen mode Exit fullscreen mode

Spam protection

// 1 hour cooldown between submissions per user
static const String _cooldownKey = 'report_problem_last_submit';
static const int _cooldownMinutes = 60;

Future<bool> _isCoolingDown() async {
  final prefs = await SharedPreferences.getInstance();
  final lastSubmit = prefs.getInt(_cooldownKey);
  if (lastSubmit == null) return false;
  final diff = DateTime.now().millisecondsSinceEpoch - lastSubmit;
  return diff < Duration(minutes: _cooldownMinutes).inMilliseconds;
}
Enter fullscreen mode Exit fullscreen mode

The security mistake I made

While setting this up, I made a mistake — I added the Telegram bot token directly to app_config.json in my public GitHub repository.

GitHub sent me a secret exposure alert within minutes.

{
  "telegram_bot_token": "8940932851:XX",
  "telegram_chat_id": "-51505959XX"
}
Enter fullscreen mode Exit fullscreen mode

Anyone with read access to the repo could see the token and control the bot.

How I fixed it

Step 1 — Revoke immediately
Went to @botfather/mybots → API Token → Revoke. New token generated in 10 seconds.

Step 2 — Make repo private
But wait — my Cloudflare Worker fetches from this GitHub repo to serve data to the app. Making it private would break everything.

The fix: add a GitHub PAT as an encrypted environment variable in Cloudflare Worker.

// In Cloudflare Worker — before:
githubResponse = await fetch(githubUrl, {
  headers: { 'User-Agent': 'DramaHub-CDN/1.0' },
});

// After:
githubResponse = await fetch(githubUrl, {
  headers: {
    'User-Agent': 'DramaHub-CDN/1.0',
    'Authorization': `token ${env.GITHUB_TOKEN}`,
  },
});
Enter fullscreen mode Exit fullscreen mode

env.GITHUB_TOKEN is stored as an encrypted secret in Cloudflare Worker settings — never in code, never visible.

Step 3 — Make repo private
With the Worker authenticated, the repo can be private. The full pipeline:

Flutter App
→ Cloudflare Worker (authenticated with GITHUB_TOKEN)
→ Private GitHub repo
→ Returns app_config.json with telegram_bot_token and telegram_chat_id
→ AppConfigService loads them at runtime
→ TelegramService uses them to send messages
Enter fullscreen mode Exit fullscreen mode

Zero hardcoding anywhere. Admin panel can update the token via app_config.json — no app update needed.


The form UI

Both screens follow the same pattern — native Flutter, fully dark themed:

TextFormField(
  style: AppTypography.body.copyWith(color: AppColors.white),
  cursorColor: AppColors.primaryRed,
  decoration: InputDecoration(
    filled: true,
    fillColor: AppColors.cardBackground, // #1C1C1C
    focusedBorder: OutlineInputBorder(
      borderSide: BorderSide(color: AppColors.primaryRed, width: 1.5),
    ),
  ),
)
Enter fullscreen mode Exit fullscreen mode

Success state replaces the form entirely after submission — no snackbar, no dismissable toast:

// After successful submit:
setState(() => _submitted = true);

// Build method:
body: _submitted ? _buildSuccessState() : _buildForm(),
Enter fullscreen mode Exit fullscreen mode

What changed for users

Before After
White Google Form in dark app Native dark themed form
Submissions silently disappear Instant Telegram notification
No contact option Optional Telegram/email field
No error handling Proper offline error state
No success feedback Full success screen

The real lesson

I was so focused on building new features that I never went back to check if existing ones were actually working.

A 3,000 DAU app is not a side project. Real people use it every day. They deserve to be heard.

Now they are.


DramaHub is live on Google Play Store. Built solo. ₹0/month infrastructure.
Architecture: Flutter + GitHub as database + Cloudflare Workers CDN + Firebase

Top comments (0)