David Serrano Canales
David Serrano

David Serrano

Create a Linux package browser in 5 minutes with Flutter 3

Create a Linux package browser in 5 minutes with Flutter 3

Take advantage of the official Flutter 3 support for Linux by creating a snap package browser using Ubuntu's visual style: Yaru.

David Serrano Canales
·Jun 1, 2022·

7 min read

In this latest article about what's new in Flutter 3, I'm going to talk about the stable support for Linux applications. In previous articles I have already talked about the performance improvements in Flutter web and for macOS applications, as well as the new features that Dart 2.17 brings. Stable support for Linux comes full circle to make Flutter one of the best cross-platform technologies out there.

📽 Video version available on YouTube and Odysee

In terms of performance, the tests that I have done do not show a significant improvement, so I understand that what they have done is simply make Linux official as a stable platform because it is in a sufficiently mature state, and the proof of that is that Canonical has used it as a tool to build some of its applications for Ubuntu.

Speaking of Canonical, they are the ones who have contributed much of the Linux-oriented plugins that we can find in pub.dev. In this article I am going to create a small application that will allow us to search for snap packages, using the yaru visual style, which is Ubuntu's own style.

For those who don't know what snap is: snap is a package manager for Linux. It is created and maintained by Canonical, and although it is not the most popular package manager, it will serve us perfectly in this tutorial to see the interconnection that we can do from Flutter with the operating system.

Create Linux application

Let's start by creating a new project and adding all the necessary dependencies:

flutter create flutter_3_linux
cd flutter_3_linux
flutter pub add yaru yaru_icons snapd provider url_launcher

The dependencies we have added are:

  • yaru: it will help us to stylize the application with the visual style of Ubuntu
  • yaru_icons: provides yaru style icons
  • snapd: this is the plugin that will allow us to interact with the snap client, it is important to mention that this client has to be previously installed in the system
  • provider: I will use this package to manage the state of the application
  • url_launcher: it will help us to open the page of the official store for the snaps that we look for within the application

Now I'm going to create a model class to handle state, a main screen, and I'm going to modify the main.dart file as follows:

// lib/main_model.dart

import 'package:flutter/material.dart';
import 'package:snapd/snapd.dart';

class MainModel extends ChangeNotifier {
  final SnapdClient _client = SnapdClient();

  @override
  void dispose() {
    super.dispose();
    _client.close();
  }
}
// lib/main_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_3_linux_2/main_model.dart';
import 'package:provider/provider.dart';

class MainScreen extends StatefulWidget {
  const MainScreen({super.key});

  @override
  State<MainScreen> createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<MainModel>(
      create: (_) => MainModel(),
      child: Consumer<MainModel>(
        builder: (context, model, child) => Scaffold(
          appBar: AppBar(
            title: const Text('Flutter 3 Linux'),
          ),
        ),
      ),
    );
  }
}
// lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_3_linux_2/main_screen.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MainScreen(),
    );
  }
}

If you run the app now you'll see that it has a typical Material look and feel:

base_app_material.png

The way to solve it is to modify main.dart as follows:

// lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_3_linux_2/main_screen.dart';
import 'package:yaru/yaru.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      // Wrap your first screen with YaruTheme to apply Yaru style
      // to all widgets underneath it
      home: const YaruTheme(
        child: MainScreen(),
      ),
    );
  }
}

As you can see, all we have to do is wrap the screen with YaruTheme, this will automatically apply the Yaru style to all the widgets below it. This way we won't have to worry about using any more yaru-style widgets, we'll just add regular widgets from the material package.

base_app_yaru.png

Now we are going to make it so that the user can search for snap packages by typing in a search criteria. Add the required variables to main_model.dart to manage the state of the search in the UI:

// lib/main_model.dart

String _searchQuery = '';

String get searchQuery => _searchQuery;

set searchQuery(String value) {
  if (value != _searchQuery) {
    _searchQuery = value;
    notifyListeners();
  }
}

Modify main_screen.dart to add the search bar:

// lib/main_screen.dart

class _MainScreenState extends State<MainScreen> {
  // Add this variables to control the search field
  final _searchController = TextEditingController();
  final _searchFocus = FocusNode();

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<MainModel>(
      create: (_) => MainModel(),
      child: Consumer<MainModel>(
        builder: (context, model, child) => Scaffold(
            appBar: AppBar(
              title: const Text('Flutter 3 Linux'),
            ),
            body: Column(
              children: [
                Container(
                  padding:
                      const EdgeInsets.only(left: 16.0, top: 16.0, right: 16.0),
                  child: TextFormField(
                    controller: _searchController,
                    focusNode: _searchFocus,
                    keyboardType: TextInputType.text,
                    decoration: InputDecoration(
                      icon: const Icon(YaruIcons.search),
                      suffixIcon: model.searchQuery.isEmpty
                          ? null
                          : IconButton(
                              icon: const Icon(
                                YaruIcons.edit_clear,
                                size: 24.0,
                              ),
                              onPressed: () {
                                model.searchQuery = '';
                                _searchController.clear();
                              },
                            ),
                      hintText: 'Search a snap...',
                    ),
                    onChanged: (String term) => model.searchQuery = term,
                  ),
                ),
              ],
            )),
      ),
    );
  }
}

This way we get a search bar in which we can write the snap package we want:

search_bar.png

Optimizing the search mechanism

Before we get into the actual package search, let's declare how we want it to work:

  • We want snap packages to be searched for when the user types characters in the search bar
  • The search is dynamic, so as the user types characters it has to change
  • It has to be efficient

The problem with this scheme is that as soon as the user types a character, a search request will be launched, which in turn will lead to a network request. In other words, if I write 10 characters in a row, the snap client will make 10 requests in a row. This is far from efficient. Let's modify the search as follows, and then see the effect we've achieved:

// lib/main_model.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:snapd/snapd.dart';

class MainModel extends ChangeNotifier {
  // We'll wait for the user to stop writing for this amount of delay
  static const _searchDelay = 1;

  final SnapdClient _client = SnapdClient();

  String _searchQuery = '';

  String get searchQuery => _searchQuery;

  set searchQuery(String value) {
    if (value != _searchQuery) {
      _searchQuery = value;
      _search(); // <-- Add this call here
      notifyListeners();
    }
  }

  Timer? _timer;

  @override
  void dispose() {
    super.dispose();
    _client.close();
  }

  void _search() {
    _timer?.cancel();

    if (_searchQuery.isNotEmpty) {
      _timer = Timer(const Duration(seconds: _searchDelay), () async {
        // Perform the snap search here
      });
    }
  }
}

Now the search will be executed only when the user finishes typing, that is, when 1 second has passed since the last time a character was entered. Even so, the search is still dynamic, if they enter more characters, the search request will be launched again.

Searching for packages and displaying them

To finish, we are going to search for snap packages using the snapd plugin. To display them in the UI I'm going to use a Stream:

// lib/main_model.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:snapd/snapd.dart';

class MainModel extends ChangeNotifier {
  static const _searchDelay = 1;

  final SnapdClient _client = SnapdClient();

  String _searchQuery = '';

  String get searchQuery => _searchQuery;

  set searchQuery(String value) {
    if (value != _searchQuery) {
      _searchQuery = value;
      _search();
      notifyListeners();
    }
  }

  Timer? _timer;

  // Add this controller and its stream here.
  final StreamController<List<Snap>> _snapController = StreamController();
  late final Stream<List<Snap>> snapStream;

  @override
  void dispose() {
    super.dispose();
    _client.close();
    _snapController.close(); // <-- Don't forget to close the controller.
  }

  void _search() {
    _timer?.cancel();

    if (_searchQuery.isNotEmpty) {
      _timer = Timer(const Duration(seconds: _searchDelay), () async {
        // Search for packages and stream them
        final snaps = await _client.find(query: _searchQuery);
        _snapController.sink.add(snaps);
      });
    } else {
      // If the search is empty, stream an empty array
      _snapController.sink.add([]);
    }
  }
}

Add the following widget as the second element of the Column in main_screen.dart, just below the Container that wraps the search bar:

Expanded(
  child: Container(
    padding: const EdgeInsets.only(
        left: 16.0, top: 16.0, right: 16.0, bottom: 16.0),
    child: StreamBuilder<List<Snap>>(
      stream: model.snapStream,
      builder: (context, snapshot) {
        if (snapshot.hasData && snapshot.data!.isNotEmpty) {
          final snaps = snapshot.data!;

          return ListView.separated(
              itemBuilder: (context, index) {
                final snap = snaps[index];

                return ListTile(
                  title: Text(snap.title),
                  subtitle: Text(
                    snap.summary,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                  trailing: Text(
                    snap.version,
                    style: Theme.of(context).textTheme.caption,
                  ),
                  onTap: snap.storeUrl != null
                      ? () async {
                          final uri = Uri.parse(snap.storeUrl!);

                          if (await canLaunchUrl(uri)) {
                            launchUrl(uri);
                          }
                        }
                      : null,
                );
              },
              separatorBuilder: (context, index) => Container(
                    padding: const EdgeInsets.only(
                      left: 16.0,
                      right: 16.0,
                    ),
                    child: const Divider(),
                  ),
              itemCount: snaps.length);
        } else {
          return Container();
        }
      },
    ),
  ),
)

You can now search for snaps packages by typing characters in the search bar. You can also click on them to open their file on the web:

app_working.png

Conclusion

In this article I have taken a simple walkthrough of building a Linux application using Flutter. If you haven't seen it yet, I invite you to check out the other articles I've written about the improvements that have been applied to Flutter 3.

You can find the complete source code of this tutorial here.

Thank you for reading this far.

Happy coding!

Did you find this article valuable?

Support David Serrano Canales by becoming a sponsor. Any amount is appreciated!

See recent sponsors Learn more about Hashnode Sponsors