O Material saiu de casa: como migrar para material_ui e cupertino_ui sem quebrar o app de produção

Tem uma linha que está em praticamente todo arquivo de UI que você já escreveu em Flutter:

import 'package:flutter/material.dart';

Ela é tão automática que a gente nem lê mais. É tipo o “bom dia” no grupo da família: sai no reflexo.

Pois é. Essa linha está com os dias contados.

No Flutter 3.47, Material e Cupertino viraram pacotes independentes no pub.dev: material_ui e cupertino_ui. As bibliotecas que vivem dentro do SDK estão congeladas desde abril e a deprecação formal está marcada para o próximo stable. Não é emergência, mas aquele “depois eu vejo” tem prazo.

Vou explicar o que muda, o que quebra de verdade e qual ordem de migração eu seguiria num app que está em produção.

Por que o Flutter fez isso

Até hoje, Material e Cupertino eram parte do framework. Resultado: um bug no DropdownMenu esperava o próximo release trimestral do SDK para ser corrigido. E se você quisesse aquela correção, precisava subir a versão do Flutter inteiro: engine, framework, toolchain, tudo junto. Era trocar o carro para consertar o retrovisor.

Com o desacoplamento:

  • Os pacotes têm ciclo de release próprio, com plano de cadência semanal. Já dá pra ver isso acontecendo: o material_ui saiu como 1.0.0 em agosto e já está na 1.5.0.
  • A contribuição acontece no repositório flutter/packages, que é bem mais leve que o flutter/flutter.
  • O core do Flutter caminha para ser “neutro de estilo”: widgets base sem opinião de design system.

A cronologia, pra não confundir:

  1. Abril/2026: code freeze do Material e do Cupertino dentro do flutter/flutter.
  2. Agosto/2026 (Flutter 3.47): material_ui e cupertino_ui 1.0.0 no pub.dev, migração opt-in.
  3. Próximo stable: deprecação formal das bibliotecas do SDK. A remoção ainda não tem data.

Ou seja: tudo que for corrigido ou criado no Material daqui pra frente só chega pra quem migrou. A versão de dentro do SDK virou foto congelada.

O problema real: você não migra sozinho

Se o seu app fosse só o seu código, a migração seria um dart fix e um café. Mas app de produção tem 40, 60, 80 dependências. Algumas são bem mantidas (o go_router já migrou na 18.0.0), outras são aquele package que ninguém atualiza desde 2023 e que você não tem coragem de tirar.

Aí aparece o cenário misto: seu código usa material_ui e uma dependência ainda importa package:flutter/material.dart. Para o Dart, são duas bibliotecas diferentes. Theme de uma não é o Theme da outra. Pior ainda: ColorScheme de uma não é o ColorScheme da outra.

É aqui que a migração pode virar dor de cabeça. Então vamos por partes.

Passo 1: a migração automática (a parte fácil)

flutter upgrade   # 3.47+
dart fix --apply --code=migrate_design_widgets

O dart fix troca os imports:

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

// Depois
import 'package:material_ui/material_ui.dart';
import 'package:cupertino_ui/cupertino_ui.dart';

Se ele não adicionar os pacotes no pubspec.yaml (a documentação avisa que pode acontecer), faça na mão:

flutter pub add material_ui
flutter pub add cupertino_ui

Depois, flutter analyze para pegar algum arquivo que ficou com import misturado. Os nomes das classes são os mesmos, então o diff fica grande mas é burro. Isso é ótimo pra quem vai revisar o PR.

Passo 2: a bridge para as dependências atrasadas

Para os packages que ainda importam o Material antigo, o material_ui traz a MaterialUiCompatibilityBridge. Ela entra no builder do app:

import 'package:material_ui/material_ui.dart';

class MyApp extends StatelessWidget {
  const MyApp({super.key, required this.router});

  final GoRouter router;

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF0468D7)),
      ),
      routerConfig: router,
      builder: (BuildContext context, Widget? child) {
        return MaterialUiCompatibilityBridge(child: child!);
      },
    );
  }
}

O que ela faz: injeta tema e localização na árvore de um jeito que os widgets “do mundo antigo” conseguem ler. Aquele package legado que chama Theme.of(context) ou MaterialLocalizations.of(context) continua funcionando, com as cores do seu tema, em vez de cair no tema padrão roxinho. (Se você já viu um dialog de package aparecer roxo no meio do seu app azul, sabe do que eu estou falando.)

Para Cupertino existe a CupertinoUiCompatibilityBridge, usada do mesmo jeito com CupertinoApp.

Passo 3: onde a bridge NÃO salva você

Essa é a parte que quase ninguém lê, e é a que derruba o build.

A bridge resolve o que é lido pelo context. Ela não resolve tipo em assinatura de API pública. Imagina um package legado de gráficos assim:

// dentro do package legado (ainda em package:flutter/material.dart)
class LegacyChartStyle {
  const LegacyChartStyle({required this.colorScheme});
  final ColorScheme colorScheme; // ColorScheme do SDK
}

E no seu app, já migrado:

import 'package:material_ui/material_ui.dart';

final style = LegacyChartStyle(
  colorScheme: Theme.of(context).colorScheme, // ColorScheme do material_ui
);

O nome é igual, o formato é igual, mas o analyzer acusa erro: o tipo não é atribuível. São duas classes distintas, de bibliotecas distintas, e o Dart tem tipagem estática. A documentação oficial cita exatamente esse caso com ColorScheme, TextTheme e FloatingActionButtonLocation.

Saídas possíveis, da melhor pra pior:

  1. Atualizar o package (verifique se já existe versão migrada).
  2. Abrir issue ou PR no package.
  3. Fazer fork temporário com dependency_overrides.
  4. Isolar o uso daquele package num arquivo que ainda importa o Material antigo e expor só tipos neutros (Color, TextStyle, que vêm de dart:ui/painting e não mudaram).

Passo 4: descubra quem está atrasado antes de começar

Em vez de descobrir no meio do caminho, faça o levantamento antes. Este script lista os packages do projeto que ainda importam o Material antigo:

jq -r '.packages[] | select(.rootUri | startswith("file://")) | .rootUri' \
  .dart_tool/package_config.json \
  | sed 's|^file://||' \
  | while read -r dir; do
      if grep -rqs "package:flutter/material.dart" "$dir/lib"; then
        basename "$dir"
      fi
    done

Pacotes do próprio SDK (flutter, flutter_test) vão aparecer na lista, pode ignorar. O resto é sua lista de pendências. Para cada item, uma pergunta: ele só lê o tema (a bridge resolve) ou expõe tipo do Material na API (precisa migrar)?

Passo 5: localização ficou mais simples

As traduções dos componentes saíram do flutter_localizations e foram para dentro dos pacotes. O setup encolheu:

// Antes
MaterialApp(
  localizationsDelegates: const <LocalizationsDelegate<dynamic>>[
    GlobalCupertinoLocalizations.delegate,
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
  ],
  supportedLocales: const [Locale('pt', 'BR')],
);

// Depois (material_ui)
MaterialApp(
  localizationsDelegates: GlobalMaterialLocalizations.delegates,
  supportedLocales: const [Locale('pt', 'BR')],
);

O GlobalMaterialLocalizations.delegates já inclui Cupertino e Widgets. Uma lista a menos para esquecer item. Se o seu app tem delegates próprios (do intl/ARB), concatene: [...GlobalMaterialLocalizations.delegates, AppLocalizations.delegate].

A ordem que eu seguiria num app de produção

  1. Design system interno primeiro. Se você tem um package de componentes compartilhado entre apps, ele é a raiz da árvore. E migrar ele é breaking change para os consumidores, então vale major version, igualzinho a orientação oficial para autores de package.
  2. Depois os apps, um por vez, com a bridge ligada.
  3. Commit do pubspec.lock (em app, sempre). Com release semanal do material_ui, você não quer o CI pegando versão nova sem ninguém ter pedido.
  4. Golden tests rodando antes e depois. O código 1.0.0 é o mesmo que estava congelado no SDK, então o visual não deveria mudar. Mas “não deveria” não é “não muda”. Golden test é o seu detector de fumaça.
  5. Remova a bridge quando o script do passo 4 voltar só com pacotes do SDK.

Impacto técnico, sem hype

  • Ganho real: correção de bug de componente sem esperar o release trimestral do Flutter. Para quem mantém app grande, isso é ouro.
  • Custo real: mais uma dependência para gerenciar e um período de convivência entre dois mundos, que exige atenção a tipos em API pública.
  • Risco de não fazer nada: quando a deprecação chegar, você vai migrar com pressa, e com pressa é que a gente faz besteira.

Conclusão

Essa é uma daquelas mudanças que parecem só “trocar import”, mas que mexem na fundação do app. A parte mecânica o dart fix faz. A parte que exige engenharia é mapear dependências, entender o limite da bridge e definir a ordem de migração.

Minha sugestão: abra uma branch essa semana, rode o dart fix, rode o script de levantamento e veja o tamanho do problema. Pode ser que seja uma tarde. Pode ser que seja um sprint. Melhor descobrir agora do que no dia em que o flutter upgrade começar a encher o terminal de warning.