Repository: zulip/zulip-flutter (language: dart)

Issue:
Update to old beta channel saying app has moved
Now that the app [has launched](https://blog.zulip.com/2025/06/17/flutter-mobile-app-launched/), we're publishing future releases to the main Zulip app's listings in the Play Store and App Store, and no longer to the listings (in the Play Store and TestFlight) that were for the Flutter app as a beta.

People who were using the Flutter beta will therefore want to install the main Zulip app and switch to that, in order to keep getting new features and bugfixes. For those who don't see the announcement through other channels, it would be good to post it in the app itself.


### Implementation

I think the shape this would take would be a small branch atop the last pre-launch beta version, v0.0.33, adding a dialog box using code similar to that in #1590.


### Spec

Two questions we'll need to pin down before shipping this:
* What should the text of the dialog box be?
* When should the dialog appear? Options include just once; or on every startup of the app; or some more-complicated description in between.




Discussion hints:
> * When should the dialog appear? Options include just once; or on every startup of the app; or some more-complicated description in between.

I vote "on every startup of the app". It's easiest to implement, and I think the goal is to be assertive but stop short of a rug-pull where we just disable all the app's features.
Yep, that plan sounds good to me.

Pull request: startup: Add beta-complete dialog
Fixes #1603.

cc @alya 
-----

<img width="559" height="1062" alt="image" src="https://github.com/user-attachments/assets/babf606f-f20f-44bf-8a48-b73334b41c36" />

<img width="340" height="755" alt="image" src="https://github.com/user-attachments/assets/8c36182e-9101-42d9-8fc7-30f5d9b82219" />


Test command: flutter test test/notifications/open_test.dart test/widgets/app_test.dart test/widgets/home_test.dart test/widgets/login_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6ae781c17d..3adbb17987 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,8 +22,14 @@ jobs:
       # so that Flutter knows its version and sees the constraint in our
       # pubspec is satisfied.  It's uncommon for flutter/flutter to go
       # more than 100 commits between tags.  Fetch 1000 for good measure.
+      # TODO(upstream): Around 2025-05, Flutter upstream stopped making
+      #   tags within the main/master branch.  Get that fixed:
+      #     https://github.com/zulip/zulip-flutter/issues/1710
+      #   Pending that, fetch more than 1000 commits.
       run: |
-        git clone --depth=1000 -b main https://github.com/flutter/flutter ~/flutter
+        git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter
+        cd ~/flutter
+        git --git-dir ~/flutter/.git checkout ee089d09b21ec3ccc20d179c5be100d2a9d9f866
         TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local
         echo ~/flutter/bin >> "$GITHUB_PATH"
 
diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml
index fb676042ff..7703fd6ec1 100644
--- a/.github/workflows/update-translations.yml
+++ b/.github/workflows/update-translations.yml
@@ -29,8 +29,9 @@ jobs:
         # so that Flutter knows its version and sees the constraint in our
         # pubspec is satisfied.  It's uncommon for flutter/flutter to go
         # more than 100 commits between tags.  Fetch 1000 for good measure.
+        # TODO(upstream): See ci.yml for why we fetch more than 1000.
         run: |
-          git clone --depth=1000 -b main https://github.com/flutter/flutter ~/flutter
+          git clone --depth=3000 -b main https://github.com/flutter/flutter ~/flutter
           TZ=UTC git --git-dir ~/flutter/.git log -1 --format='%h | %ci | %s' --date=iso8601-local
           echo ~/flutter/bin >> "$GITHUB_PATH"
 
diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart
index b1aa763ac8..d3e729f87b 100644
--- a/lib/widgets/app.dart
+++ b/lib/widgets/app.dart
@@ -160,6 +160,10 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver {
   void initState() {
     super.initState();
     WidgetsBinding.instance.addObserver(this);
+
+    // On every startup is fine; the goal is to be assertive but stop short of a
+    // rug-pull where we just disable all the app's features.
+    BetaCompleteDialog.show();
   }
 
   @override
diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart
index 4d269cddba..4d6d3e8675 100644
--- a/lib/widgets/dialog.dart
+++ b/lib/widgets/dialog.dart
@@ -1,7 +1,11 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
 
 import '../generated/l10n/zulip_localizations.dart';
 import 'actions.dart';
+import 'app.dart';
 
 Widget _dialogActionText(String text) {
   return Text(
@@ -112,3 +116,87 @@ DialogStatus<bool> showSuggestedActionDialog({
       ]));
   return DialogStatus(future);
 }
+
+bool debugDisableBetaCompleteDialog = false;
+
+/// A brief dialog box saying that this beta channel has ended,
+/// offering a way to get the app from prod.
+///
+/// Shown on every startup.
+class BetaCompleteDialog extends StatelessWidget {
+  const BetaCompleteDialog._();
+
+  static void show() async {
+    if (debugDisableBetaCompleteDialog) return;
+
+    final navigator = await ZulipApp.navigator;
+    final context = navigator.context;
+    assert(context.mounted);
+    if (!context.mounted) return; // TODO(linter): this is impossible as there's no actual async gap, but the use_build_context_synchronously lint doesn't see that
+
+    switch (defaultTargetPlatform) {
+      case TargetPlatform.android:
+      case TargetPlatform.iOS:
+        break;
+      case TargetPlatform.macOS:
+      case TargetPlatform.fuchsia:
+      case TargetPlatform.linux:
+      case TargetPlatform.windows:
+        // Do nothing on these unsupported platforms.
+        return;
+    }
+
+    unawaited(showDialog(
+      context: context,
+      builder: (BuildContext context) => BetaCompleteDialog._()));
+  }
+
+  Widget _linkButton(BuildContext context, {
+    required String url,
+    required String label,
+  }) {
+    return TextButton(
+      onPressed: () {
+        Navigator.pop(context);
+        PlatformActions.launchUrl(context,
+          Uri.parse(url));
+      },
+      child: _dialogActionText(label));
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final message = 'Thanks for being a beta tester of the new Zulip app!'
+      ' This app became the main Zulip mobile app in June 2025,'
+      ' and this beta version is no longer maintained.'
+      ' We recommend uninstalling this beta after switching'
+      ' to the main Zulip app, in order to get the latest features'
+      ' and bug fixes.';
+
+    return AlertDialog(
+      title: Text('Time to switch to the new app'),
+      content: SingleChildScrollView(child: Text(message)),
+      actions: [
+        TextButton(
+          onPressed: () => Navigator.pop(context),
+          child: _dialogActionText('Got it')),
+        ...(switch (defaultTargetPlatform) {
+            TargetPlatform.android => [
+              _linkButton(context,
+                url: 'https://github.com/zulip/zulip-flutter/releases/latest',
+                label: 'Download official APKs (less common)'),
+              _linkButton(context,
+                url: 'https://play.google.com/store/apps/details?id=com.zulipmobile',
+                label: 'Open Google Play Store'),
+            ],
+            TargetPlatform.iOS => [
+              _linkButton(context,
+                url: 'https://apps.apple.com/app/zulip/id1203036395',
+                label: 'Open App Store'),
+            ],
+            TargetPlatform.macOS || TargetPlatform.fuchsia
+              || TargetPlatform.linux || TargetPlatform.windows => throw UnimplementedError(),
+          }),
+      ]);
+  }
+}


Test patch:
diff --git a/test/notifications/open_test.dart b/test/notifications/open_test.dart
index a2c14ca20a..2e2eddc0b2 100644
--- a/test/notifications/open_test.dart
+++ b/test/notifications/open_test.dart
@@ -13,6 +13,7 @@ import 'package:zulip/model/narrow.dart';
 import 'package:zulip/notifications/open.dart';
 import 'package:zulip/notifications/receive.dart';
 import 'package:zulip/widgets/app.dart';
+import 'package:zulip/widgets/dialog.dart';
 import 'package:zulip/widgets/home.dart';
 import 'package:zulip/widgets/message_list.dart';
 import 'package:zulip/widgets/page.dart';
@@ -76,6 +77,8 @@ void main() {
   final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
 
   Future<void> init({bool addSelfAccount = true}) async {
+    debugDisableBetaCompleteDialog = true;
+    addTearDown(() => debugDisableBetaCompleteDialog = false);
     if (addSelfAccount) {
       await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
     }
diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart
index 7b1388dbec..ccbed9cd5a 100644
--- a/test/widgets/app_test.dart
+++ b/test/widgets/app_test.dart
@@ -7,6 +7,7 @@ import 'package:zulip/log.dart';
 import 'package:zulip/model/actions.dart';
 import 'package:zulip/model/database.dart';
 import 'package:zulip/widgets/app.dart';
+import 'package:zulip/widgets/dialog.dart';
 import 'package:zulip/widgets/home.dart';
 import 'package:zulip/widgets/page.dart';
 
@@ -27,6 +28,8 @@ void main() {
     late List<Route<dynamic>> pushedRoutes = [];
 
     Future<void> prepare(WidgetTester tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
 
       pushedRoutes = [];
@@ -64,6 +67,8 @@ void main() {
     late List<Route<void>> poppedRoutes;
 
     Future<void> prepare(WidgetTester tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
 
       pushedRoutes = [];
@@ -279,6 +284,8 @@ void main() {
     });
 
     testWidgets('choosing an account clears the navigator stack', (tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
       await testBinding.globalStore.add(eg.otherAccount, eg.initialSnapshot());
@@ -391,6 +398,8 @@ void main() {
     });
 
     testWidgets('reportErrorToUserBriefly with details', (tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       await tester.pumpWidget(const ZulipApp());
       const message = 'test error message';
@@ -418,6 +427,8 @@ void main() {
     });
 
     Future<void> prepareSnackBarWithDetails(WidgetTester tester, String message, String details) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       await tester.pumpWidget(const ZulipApp());
       await tester.pump();
@@ -484,6 +495,8 @@ void main() {
     });
 
     testWidgets('reportErrorToUserModally', (tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       await tester.pumpWidget(const ZulipApp());
       const title = 'test title';
diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart
index 1b5c8ad8b5..86a33cb699 100644
--- a/test/widgets/home_test.dart
+++ b/test/widgets/home_test.dart
@@ -8,6 +8,7 @@ import 'package:zulip/model/store.dart';
 import 'package:zulip/widgets/about_zulip.dart';
 import 'package:zulip/widgets/app.dart';
 import 'package:zulip/widgets/app_bar.dart';
+import 'package:zulip/widgets/dialog.dart';
 import 'package:zulip/widgets/home.dart';
 import 'package:zulip/widgets/icons.dart';
 import 'package:zulip/widgets/inbox.dart';
@@ -48,6 +49,8 @@ void main () {
     ..onPopped = ((route, prevRoute) => lastPoppedRoute = route);
 
   Future<void> prepare(WidgetTester tester) async {
+    debugDisableBetaCompleteDialog = true;
+    addTearDown(() => debugDisableBetaCompleteDialog = false);
     addTearDown(testBinding.reset);
     topRoute = null;
     previousTopRoute = null;
@@ -272,6 +275,8 @@ void main () {
     });
 
     testWidgets('menu buttons dismiss the menu', (tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       topRoute = null;
       previousTopRoute = null;
@@ -328,6 +333,8 @@ void main () {
     }
 
     Future<void> prepare(WidgetTester tester) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
       topRoute = null;
       previousTopRoute = null;
@@ -521,6 +528,8 @@ void main () {
   });
 
   testWidgets('logging out while still loading', (tester) async {
+    debugDisableBetaCompleteDialog = true;
+    addTearDown(() => debugDisableBetaCompleteDialog = false);
     // Regression test for: https://github.com/zulip/zulip-flutter/issues/1219
     addTearDown(testBinding.reset);
     await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
@@ -537,6 +546,8 @@ void main () {
   });
 
   testWidgets('logging out after fully loaded', (tester) async {
+    debugDisableBetaCompleteDialog = true;
+    addTearDown(() => debugDisableBetaCompleteDialog = false);
     // Regression test for: https://github.com/zulip/zulip-flutter/issues/1219
     addTearDown(testBinding.reset);
     await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
diff --git a/test/widgets/login_test.dart b/test/widgets/login_test.dart
index a5109ba5db..9cb646f11b 100644
--- a/test/widgets/login_test.dart
+++ b/test/widgets/login_test.dart
@@ -12,6 +12,7 @@ import 'package:zulip/model/binding.dart';
 import 'package:zulip/model/database.dart';
 import 'package:zulip/model/localizations.dart';
 import 'package:zulip/widgets/app.dart';
+import 'package:zulip/widgets/dialog.dart';
 import 'package:zulip/widgets/home.dart';
 import 'package:zulip/widgets/login.dart';
 import 'package:zulip/widgets/page.dart';
@@ -83,6 +84,8 @@ void main() {
 
     Future<void> prepare(WidgetTester tester,
         GetServerSettingsResult serverSettings) async {
+      debugDisableBetaCompleteDialog = true;
+      addTearDown(() => debugDisableBetaCompleteDialog = false);
       addTearDown(testBinding.reset);
 
       connection = testBinding.globalStore.apiConnection(