Flutter, JetPack Compose, Swift UI e Solidariedade. Flutter, JetPack Compose, Swift UI and Solidarity. Flutter — Parte I — Telas. Flutter — Part I — Screens.

Ricardo Ogliari
16 min readSep 10, 2022

--

A primeira parte pode ser encontrada aqui: https://medium.com/p/cb6085e2d207. The first part can be found here: https://medium.com/p/cb6085e2d207.

Github: https://github.com/ricardoogliari/doted_flutter/tree/step1/initial_screens

Para desenvolver o projeto no Flutter vou usar o Android Studio, porém, também é possível codificar para esta plataforma usando o Visual Studio Code. Se você está começando com Flutter e ainda não possui seu ambiente instalado e configurado, deixo aqui o link oficial para sanar este problema: https://docs.flutter.dev/get-started/install. To develop the project in Flutter I’m gonna use Android Studio, however, it’s also possible to code for this platform using Visual Studio Code. If you are starting with Flutter and still don’t have your environment installed and configured, go to the official link to solve this problem: https://docs.flutter.dev/get-started/install.

O primeiro passo para criar o projeto é navegar pelo menu File -> New -> New Flutter Project. No wizard apresentado precisamos fazer algumas configurações de forma visual. O primeiro passo é indicar o local de instalação do Flutter SDK. Basta indicar o local correto e clicar em Next. The first step to create the project is to navigate through the menu File -> New -> New Flutter Project. In the presented wizard we need to make some settings visually. The first step is to indicate the Flutter SDK installation location. Simply indicate the correct location and click Next.

Figura 1: escolha do Flutter SDK no Android Studio.

No passo seguinte precisamos configurar o nome do projeto (usei doted), a localização do projeto, a descrição do mesmo, seu tipo (defini Application) e a organização. Esse último campo tem o mesmo intuito do nome do pacote do Java, por exemplo. In the next step we need to configure the name of the project (I used doted), the location of the project, its description, its type (I defined Application) and the organization. This last field has the same purpose as the Java package name, for example.

Na sequência, temos a linguagem para o Android e para o iOS, que já vem marcadas com Kotlin e Swift, que, no final de 2022, são as líderes de mercado nas respectivas plataformas nativas. Porém, isso pode causar uma confusão. Afinal, Flutter não usa linguagem de programação DART? O que o Kotlin e Swift estão fazendo aí? Bem, eles serão usados caso a aplicação faça uso do Platform Channel (não aprofundarei neste assunto neste texto, sendo assim, segue documentação -> https://docs.flutter.dev/development/platform-integration/platform-channels) e, também, para as configurações iniciais da engine Flutter na plataforma nativa (Android e iOS). Next, we have the language for Android and iOS, which is already marked with Kotlin and Swift. Both languages are the market leaders in their respective native platforms (end of 2022). However, this can cause confusion. After all, doesn’t Flutter use DART programming language? What are Kotlin and Swift doing there? They’re going to be used if the application makes use of the Platform Channel (I will not delve into this subject in this text, so follow the documentation -> https://docs.flutter.dev/development/platform-integration/platform-channels) and, also, for the initial settings of the Flutter engine on the native platform (Android and iOS).

Finalmente, temos as plataformas suportadas pela aplicação Flutter inicialmente. No momento optei por Android e iOS. No futuro, se percebermos que todos os pacotes usados no projeto também fornecer suporte ao Flutter Web, adicionaremos suporte também para Web. Finally, we have the platforms supported by the Flutter application initially. At the moment I opted for Android and iOS. In the future, if we see that all packages used in the project also support Flutter Web, we will add support for Web as well.

Figura 2: Configurações para criação do projeto. Settings for project creation.

Observação: Não vou falar sobre a estrutura do projeto criado, pois ao longo do desenvolvimento vamos entendendo e trabalhando nas pastas e arquivos. Note: I’m not going to talk about the structure of the project created, because throughout development we’re going to understand and work on folders and files.

Primeiramente vamos editar o arquivo pubspec.yaml. Neste arquivo podemos encontrar algumas configurações que fizemos na criação do projeto. Além disso, neste momento iremos indicar alguns pacotes que usaremos na aplicação. Para fazer isso teremos a subção dependencies e a dev_dependencies. A diferença é que no último caso, o pacote é usado só no momento de desenvolvimento. First, let’s edit the pubspec.yaml file. In this file, we can find some settings that we made when creating the project. Furthermore, at this moment we will indicate some packages that we will use in the application. To do this we have the dependencies and the dev_dependencies subsections. The difference is that in the latter case, the package is only used at development time.

Veja como ficará a sub-seção dependencies do arquivo pubspec.yaml. O pacote google_maps_flutter é auto-explicativo. Porém, também precisamos capturar nosso posicionamento geográfico, lendo os dados de latitude e longitude, sendo assim, temos o geolocator para resolver este problema. Depois de editar o arquivo, clique na opção Pub get que aparecerá na parte superior e direita do código. See how the dependencies subsection of the pubspec.yaml file will look like. The google_maps_flutter package is self-explanatory. However, we also need to capture our geographic positioning, reading the latitude and longitude data, so we have the geolocator to solve this problem. After editing the file, click on the Pub get option that will appear at the top and right of the code.

Observação 1: se o leitor estiver usando o Visual Studio Code, basta salvar o arquivo que o Pub get já é feito automaticamente. Observação 2: quer saber mais sobre os pacotes Flutter, suas configurações e versionamentos? Deixo aqui o link https://docs.flutter.dev/development/packages-and-plugins/using-packages. Note 1: if the reader is using Visual Studio Code, just save the file and the Pub get is already done automatically.
Note 2: Want to know more about Flutter packages, their configurations and versioning? Go to the link
https://docs.flutter.dev/development/packages-and-plugins/using-packages.

dependencies:
flutter:
sdk: flutter


# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2
google_maps_flutter: ^2.1.8
geolocator: ^8.0.0

Antes de codarmos a main.dart, precisamos falar sobre dois assuntos que são essenciais dentro do mundo Flutter. Quase tudo no Flutter é uma Widget, imagine o mesmo como um componente reutilizável, muito semelhantes a conceitos como Fragment no Android nativo ou, o Component do React. E o segundo conceito é State. Temos o StatelessWidget, que não há alteração do estado e, temos o StatefullWidget, que pode haver mudanças no estado, porém, a Widget em si sempre será imutável. Indico esta documentação para conhecer mais a fundo estes conceitos: https://docs.flutter.dev/development/ui/widgets-intro. Before we code main.dart, we need to talk about two subjects that are essential in the Flutter world. Almost everything in Flutter is a Widget, imagine it as a reusable component, very similar to concepts like Fragment in native Android or the Component in React. And the second concept is State. We have the StatelessWidget, which does not change the state, and we have the StatefullWidget, which can have state changes, but the Widget itself will always be immutable. I recommend this documentation to learn more about these concepts: https://docs.flutter.dev/development/ui/widgets-intro.

Agora sim, vamos codar um pouco mais. Primeiro passo é criar uma pasta model e mais quatro arquivos dart. Deixe a estrutura da sua pasta lib da forma apresentada na Figura abaixo: Now, let’s code a little more. First step is to create a model folder and four more dart files. The structure of your lib folder will look like the figure below:

Figura 3: Pastas e arquivos que devem ser criados. Folders and files that must be created.

story.dart

O primeiro arquivo que vamos codar é o story.dart. Abaixo, segue sua primeira versão: The first file we are going to code is the story.dart. Below is its first version:

class Story {

final double latitude;
final double longitude;
final String title;
final String snippet;
final int agree;
final int disagree;
//distance não é final porque vamos fazer o geocoding com latitude
//e longitude para chegar até a distância
String distance = "...";
//construtor básico com todos as propriedades obrigatórias.
Story(this.latitude, this.longitude, this.title, this.snippet, this.agree, this.disagree);

}

Antes de passar para o próximo código é importante conhecermos a primeira versão da nossa interface. Ao longo do projeto poderemos mudar isso. Aliás, muito provavelmente mudaremos! Before moving on to the next code, it is important that we know the first version of our interface. Over the course of the project we will be able to change this. In fact, we will most likely change!

Perceba que nosso aplicativo possui apenas uma tela neste momento. Porém, dividimos em duas abas, uma para visualização das histórias em um mapa e outro em lista, já definindo a distância e ordenando do mais próximo para o mais distante. Em versões futuras poderemos implementar filtros. Note that our app only has one screen at this point. However, we divided it into two tabs, one for viewing the stories on a map and another in a list, already defining the distance and ordering from closest to farthest. In future versions, we may implement filters.

Figura 4: Tela inicial da aplicação com suas tabs. Application home screen with its tabs

item_list.dart

Na tela de lista, teremos diversos componentes menores, que serão replicados na tab. Obviamente que o leitor já imagina que estes sub-componentes são um primeiro exemplo de Widget :). Sendo assim, vamos codar a classe ItemList e nos aprofundarmos nestes conceitos. On the list screen, we will have several smaller components, which will be replicated in the tab. Obviously, the reader already imagines that these sub-components are a first example of a Widget :). So, let’s code the ItemList class and delve into these concepts.

import 'package:doted/model/story.dart';
import 'package:flutter/material.dart';

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

final Story story;

@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
leading: const Icon(Icons.account_circle_outlined),
trailing: Text(story.distance),
title: Text(story.title),
subtitle: Text(story.snippet),
),
);
}

O construtor da classe ItemList recebe uma instância de Story. Como estamos com um StatelessWidget, nosso estado não muda, logo, precisamos apenas reescrever o build. Nesta última função, estamos retornando um Padding com um ListTile como filho. Neste componente visual, apenas populamos usas propriedades com os valores da instância de Story. The ItemList class’s constructor receives an instance of Story. As we have a StatelessWidget, our state doesn’t change, so we just need to rewrite the build. In this last function, we are returning a Padding with a ListTile as a child. In this visual component, we only populate its properties with the values of the Story instance.

Os itens precisam ser usados pela classe que irá popular a tab de visualização no formato de lista. Estamos falando da ListTab, mostrada na sequência: The items must be used by the class that will populate the view tab in list format. We are talking about the ListTab, shown below:

import 'package:doted/item_list.dart';
import 'package:doted/model/story.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';

class ListTab extends StatelessWidget {
const ListTab({Key? key, required this.stories, this.position}) : super(key: key);

final List<Story> stories;
final Position? position;

@override
Widget build(BuildContext context) {
return ListView.separated(
itemBuilder: (BuildContext context, int index) => buildListItem(stories.elementAt(index)),
separatorBuilder: (BuildContext context, int index) => const Divider(height: 1),
itemCount: stories.length);
}

Widget buildListItem(Story story) {
if (position != null){
double distance = Geolocator.distanceBetween(
story.latitude,
story.longitude,
position!.latitude,
position!.longitude).toInt() / 1000;
story.distance = "${distance.toStringAsFixed(2)} km";
}

return ItemList(story: story);
}

}

O ListTab também é um StatelessWidget, sendo assim, o build ditará a reconstrução da widget de 1 até N vezes. Neste método estamos criando uma instância de ListView, passando funções que irão construir cada item da lista, seu separador, bem como, o número de elementos no ListView (propriedade itemCount). The ListTab is also a StatelessWidget, so the build will dictate rebuilding the widget from 1 to N times. In this method we are creating a ListView instance, passing functions that will build each item in the list, its separator, as well as the number of elements in the ListView (itemCount property).

O ponto mais importante aqui é que a maior parte da função buildListItem é para calcular a distância da posição corrente com a posição da história contada. Na última linha da função apenas construímos uma instância da classe ItemList, que havíamos estudado anteriormente. The most important point here is that most of the buildListItem function is for calculating the distance from the current position to the position of the story told. In the last line of the function we just build an instance of the ItemList class, which we had studied earlier.

map_tab.dart

Depois de vermos a apresentação no formato lista, vamos para a visualização em forma de mapa. O pacote utilizado, o google_maps_flutter, necessita de algumas configurações relacionadas as plataformas nativas Android e iOS. Sendo assim, sugiro que o leitor veja estes detalhes antes de seguir. A página da documentação oficial do pacote pode ser encontrada aqui: https://pub.dev/packages/google_maps_flutter. After seeing the presentation in list format, let’s go to the map view. The package used, google_maps_flutter, needs some settings related to native platforms (android and iOS). Therefore, I suggest that the reader see these details before proceeding. The official package documentation page can be found here: https://pub.dev/packages/google_maps_flutter.

Como a classe gerada aqui é um pouco mais extensa, vou dividir a explicação em duas partes. Na primeira listagem de código vou mostrar o construtor, variáveis, constentes e apenas o esqueleto das funções. The class generated here is a little longer, so I’ll split the explanation into two parts. In the first code listing I will show the constructor, variables, constants and just the skeleton of the functions.

No construtor estamos criando a instância de CameraPosition. Ao trabalharmos com mapas 2D, o mesmo sempre ficará fixo, o que vai mudar é a posição da câmera que mostra um pedaço deste mesmo mapa. Sendo assim, esta câmera irá mostrar um ponto específico (uma instância de LatLng) com um nível de zoom desejado. No mesmo construtor, também chamamos a função buildMarkers. Os marcadores do mapa, também chamados de POI (Point of Interest) indicam pontos georefenciados. In the constructor we are creating the CameraPosition instance. When working with 2D maps, it will always be fixed, what will change is the position of the camera that shows a piece of the same map. Therefore, this camera will show a specific point (a LatLng’s instance) with a desired zoom level. In the same constructor, we also call the buildMarkers function. Map markers, also called POI (Point of Interest) indicate georeferenced points.

import 'dart:async';
import 'package:doted/model/story.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';

class MapTab extends StatelessWidget {
MapTab({Key? key, this.stories, this.position}) : super(key: key){
_initialPosition = CameraPosition(
target: LatLng(
position?.latitude ?? 0,//-28.264329,
position?.longitude ?? 0),
zoom: 14.4746,
);

buildMarkers();
}

final List<Story>? stories;
final Position? position;

List<Marker> _customMarkers = [];

final Completer<GoogleMapController> _controller = Completer();

late final CameraPosition _initialPosition;

void buildMarkers() { ... }

@override
Widget build(BuildContext context) { ... }
}

A função buildMarkers é um pouco mais simples que o construtor. Estamos apenas iterando sobre a listagem de histórias, onde, cria-se uma instância de Marker para cada item. No marcador estamos setando o id, a posição e, o infoWindow, que é a janela que será exibida ao clicar no marker. The buildMarkers function is a little simpler than the constructor. We’re just iterating over the story listing, where we create a Marker instance for each item. In the marker we are setting the id, the position and the infoWindow, which is the window that will be displayed when clicking on the marker.

void buildMarkers() {
stories?.forEach((story) {
_customMarkers.add(Marker(
markerId: MarkerId("${story.latitude}|${story.longitude}"),
position: LatLng(story.latitude, story.longitude),
infoWindow: InfoWindow(
title: story.title,
snippet: "${story.snippet.substring(0, 40)}..."
)
));
});
}

Finalmente, teremos a função build. O Scaffold é usado em conjunto com o MaterialApp. No seu body estamos informando apenas o GoogleMap, para ocupar toda a tela. No construtor da classe estamos definindo o tipo de mapa, sua posição inicial, uma função que será chamada quando o mapa for criado e, os marcadores que serão exibidos. Finally, we have the build function. Scaffold is used in conjunction with MaterialApp. In your body we are only informing the GoogleMap, to occupy the entire screen. In the class constructor we are defining the type of map, its starting position, a function that will be called when the map is created and the markers that will be displayed.

@override
Widget build(BuildContext context) {
return Scaffold(
body: GoogleMap(
mapType: MapType.terrain,
initialCameraPosition: _initialPosition,
onMapCreated: (GoogleMapController controller) {
_controller.complete(controller);
},
markers: _customMarkers.toSet(),
),
);
}

main.dart

Todo projeto Flutter terá um main.dart, que será o ponto de entrada da aplicação. Logo no topo, teremos uma função main, que fará uma chamada ao runApp e, uma Widget deve ser passada como parâmetro. Every Flutter project will have a main.dart, which will be the application’s entry point. At the top, we have a main function, which make a call to the runApp and a Widget must be passed as a parameter.

void main() {
runApp(const MyApp());
}

No runApp, passamos uma instância da classe MyApp, que pode ser visualizada abaixo. Novamente um StatelessWidget, que está devolvendo um MaterialApp como Widget no build. In runApp, we pass an instance of the MyApp class, which can be seen below. Again a StatelessWidget, which is returning a MaterialApp as a Widget in the build.

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

// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Dot',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}

Já na classe MyHomePage temos nossa primeira StatefullWidget. O array stories só existe porque ainda não implementamos a parte do consumo dos serviços, onde teremos as históriais reais. Na sequência, apenas sobrescrevemos o createState, que faz parte do ciclo de vida deste tipo de Widget. Lembrando, toda widget é IMUTÁVEL. O que pode alterar, no StatefullWidget, é o seu estado. In the MyHomePage class, we have our first StatefullWidget. The stories array only exists because we have not yet implemented the service consumption part, where we will have the real stories. Next, we just override createState, which is part of the lifecycle of this type of Widget. Remembering, every widget is IMMUTABLE. What can change, in StatefullWidget, is its state.

class MyHomePage extends StatefulWidget {
final List<Story> stories = [
Story(-28.263507236638343, -52.39932947157066, "Harosin", "Sollicitudin aliquam ipsum aptent id dictumst ligula curae libero senectus aliquet, cubilia scelerisque laoreet aliquet tempor quis fermentum ullamcorper interdum erat, massa placerat cubilia torquent arcu praesent tempor erat aptent.", 0, 0),
Story(-28.260889627977562, -52.400177049595555, "Dagar", "Viverra sodales vitae congue iaculis interdum class primis hac proin bibendum, diam erat ut aenean viverra gravida venenatis elit pulvinar conubia, primis est dui feugiat curae hac mauris egestas sodales. ", 0, 0),
Story(-28.261456624477784, -52.39208750743389, "Arve", "Habitant bibendum vel habitasse cursus quis sollicitudin dapibus tristique, congue suspendisse ut aptent ut tincidunt nam libero luctus, lorem ullamcorper quam ultricies congue curae pharetra. consectetur lacus faucibus sodales, imperdiet. ", 0, 0),
Story(-28.26597358890354, -52.402505206969835, "Husaol", "Pharetra pretium donec commodo torquent vestibulum class turpis, purus sed gravida dolor dictumst auctor adipiscing, mattis eros venenatis nostra augue rutrum. euismod malesuada etiam tellus cras fames, convallis donec sociosqu. ", 0, 0),
Story(-28.2705761904961, -52.39146318305821, "Tagalan", "Felis senectus habitasse facilisis torquent quis consectetur class, bibendum quam libero arcu pharetra proin iaculis nisl, praesent sed adipiscing nec nam iaculis. potenti imperdiet pellentesque facilisis nisl, quisque cursus purus. ", 0, 0),

Story(-28.242828, -52.381907, "Hiesaipen", "Mauris accumsan hendrerit consequat pharetra torquent elementum curabitur, etiam sed adipiscing cras vel tellus, a donec augue eu eget himenaeos. ", 0, 0),
Story(-28.241999, -52.438139, "Zokgaelu", "Nullam rutrum dictum mauris fermentum cursus quis fusce, litora augue pulvinar sem primis egestas, risus erat vestibulum curabitur lorem libero. ", 0, 0),
Story(-28.181835, -52.328675, "Curuas", "Tempor accumsan libero consequat phasellus tellus nullam mi, nibh placerat sagittis magna himenaeos tempus, rutrum proin lacus imperdiet ad nisl.", 0, 0),
Story(-28.124979, -52.296718, "Buigrak", "Vulputate elementum sem bibendum ad pretium pellentesque metus, nostra quisque in dolor lectus mollis, ante donec sapien netus laoreet congue. ", 0, 0),
Story(-28.107857, -52.145972, "Lomoa", "Dolor quisque tellus purus sagittis potenti ipsum nunc, porttitor magna sit aliquam erat lacinia, a phasellus curabitur diam tempus primis. ", 0, 0)
];

MyHomePage({Key? key}) : super(key: key);

@override
State<MyHomePage> createState() => _MyHomePageState();
}

E o grand finale será na _MyHomePageState. Começaremos pela sua estrutura básica e pela única variável que a mesma possui: _currentPosition. And the grand finale will be at _MyHomePageState. We’ll start with its basic structure and the only variable it has: _currentPosition.

class _MyHomePageState extends State<MyHomePage> {

Position? _currentPosition;

Future<Position> _determinePosition() async { ... }

@override
void initState() { ... }

@override
Widget build(BuildContext context) { ... }

}

No _determinePosition tentamos recuperar a posição geográfica atual do dispositivo, lembrando que antes disso precisamos checar todas as permissões necessárias. Perceba também o uso do async-await, necessário para trabalhar com funções assíncronas que retornam um Future. Observação: isso também seria possível com o uso do .then. In _determinePosition we try to retrieve the current geographical position of the device, remembering that before that we need to check all the necessary permissions. Also notice the use of async-await, necessary to work with async functions that return a Future. Note: This would also be possible using .then.

Future<Position> _determinePosition() async {
bool serviceEnabled;
LocationPermission permission;

serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
return Future.error('Location services are disabled.');
}

permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
return Future.error('Location permissions are denied');
}
}

if (permission == LocationPermission.deniedForever) {
return Future.error(
'Location permissions are permanently denied, we cannot request permissions.');
}

return await Geolocator.getCurrentPosition();
}

A última função estudada será acionada no initState, que é o início do ciclo de vida do State e será chamado apenas uma vez, diferentemente do build, que será chamado de 1 até N vezes. Antes foi usado o async-await, desta vez, o .then. Como o resultado está envolto pelo método setState, o State passará para o status dirty e o build será chamado novamente de forma automática. The last function studied will be called in initState, which is the beginning of the State’s life cycle and will be called only once, unlike build, which will be called from 1 to N times. Async-await was used before, this time o .then. As the result is wrapped by the setState method, the State will change to dirty status and the build will be called again automatically.

@override
void initState() {
super.initState();
_determinePosition().then((value) {
setState(() {
_currentPosition = value;
});
});
}

Já a função build será apenas um arcabouço para usar as classes StatelessWidget que criamos anteriormente. Um novo conceito que não foi abordado ainda e está sendo utilizado aqui é a chace de uma Key. Perceba que ela é usada no MapTab. Este conceito é um pouco complexo para ser explicado aqui. Indico esta ótima leitura: https://dhruvnakum.xyz/keys-in-flutter-uniquekey-valuekey-objectkey-pagestoragekey-globalkey. Deste texto, vou parafrasear esta frase: Keys preserves the state when you move around the widget tree. The build function will just be a container to use the StatelessWidget classes we created earlier. A new concept that hasn’t been addressed yet and is being used here is the idea of a Key. Note that it is used in MapTab. This concept is a bit complex to be explained here. I recommend this great read: https://dhruvnakum.xyz/keys-in-flutter-uniquekey-valuekey-objectkey-pagestoragekey-globalkey. From this text, I will paraphrase this sentence: Keys preserves the state when you move around the widget tree.

@override
Widget build(BuildContext context) {
return DefaultTabController(
initialIndex: 0,
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('We need help'),
bottom: const TabBar(
tabs: <Widget>[
Tab(
icon: Icon(Icons.map_outlined),
),
Tab(
icon: Icon(Icons.list),
)
],
),
),
body: TabBarView(
children: <Widget>[
Center(
child: MapTab(
key: UniqueKey(),
stories: widget.stories,
position: _currentPosition)
),
Center(
child: ListTab(
stories: widget.stories,
position: _currentPosition),
)
],
),
),
);
}

Observação: Nas próximas semanas irei postar a mesma versão, porém, usando o JetPack Compose. Note: In the next few weeks I will post the same version, however, using JetPack Compose.

--

--

No responses yet