- Flutter
- Desktop apps
- Windows
Flutter on Windows: installers, updates and receipt printers
Lessons from building Flutter Windows apps for shops: per-user Inno Setup installers, silent updates, local SQLite, backups and thermal receipt printing.
8 min read
Most Flutter tutorials stop at “flutter build windows”. That command gives you a folder, not a product. Between that folder and a shop owner who uses the app every day sit an installer, an update path, a place for the data, a backup plan and a printer that does not behave like the preview.
I have built several Windows apps in Flutter for small businesses: Asaan Optics Desktop Pro, my own SaaS for optical shops; Asaan Rental Pro, an offline app for landlords; Tabarak POS, a restaurant point of sale; Drivoli Optics, a multi-branch client build of the Asaan Optics desktop app; and Asaan Jewellers, a jewelry POS still in development. These lessons cost me the most time. They are for developers doing the same work and for business owners curious about what goes into counter PC software.
The installer is part of the product
A Flutter Windows release build is an .exe, flutter_windows.dll, a data folder with assets and compiled Dart code, and one DLL per native plugin. I package every app with Inno Setup, which is free, scriptable and produces a single setup file.
The most important setting is who the installer runs as. Before version 6.7.6, the Asaan Optics installer needed administrator rights, so every update raised a UAC prompt, and the person at a shop counter often does not have the admin password. Version 6.7.6 moved to a per-user install, as Asaan Rental Pro and Tabarak POS already do. The app goes into the user’s own profile and setup never asks for administrator rights.
Runtime dependencies come next. On a fresh Windows install, a Flutter app can fail with a missing-DLL error that no shop owner will read. The Drivoli Optics installer checks the registry and installs the Visual C++ 2015-2022 x64 runtime silently, only when it is missing. If you use the printing package, make sure its PDF rendering DLLs are in the installer too; Tabarak’s installer bundles them explicitly.
The build also has to be repeatable. For Drivoli I wrote a PowerShell script that runs flutter clean and a release build, copies the runtime DLLs, writes the version number into the Inno Setup script and compiles it, so the installer and the app cannot disagree about their version.
The per-user part is short. With PrivilegesRequired=lowest, {autopf} resolves to the per-user programs folder instead of Program Files:
; Simplified illustration of a per-user Inno Setup script
[Setup]
AppName=Example Shop
AppVersion=1.4.0
DefaultDirName={autopf}\Example Shop
PrivilegesRequired=lowest
ArchitecturesInstallIn64BitMode=x64compatible
Compression=lzma2
SolidCompression=yes
OutputBaseFilename=ExampleShop_Setup
[Files]
Source: "build\windows\x64\runner\Release\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion
[Icons]
Name: "{autoprograms}\Example Shop"; Filename: "{app}\example_shop.exe"Updates without an admin password
Without self-updates, every customer ends up on a different version. For Asaan Optics Desktop Pro, I publish a small version.json file on the website next to the installer. The app reads it, compares versions and, if a newer build exists, downloads the installer and runs it silently.
The file also carries a minimum supported version. If the installed app is older than that, the update stops being optional. That one field gives me a way to retire old builds, for example when a backend change would break them, instead of leaving them half working.
This depends on the per-user installer. A silent update that triggers a UAC prompt is not silent, and one that stalls because nobody can enter the admin password is worse than none. A simplified sketch, not the production code:
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:pub_semver/pub_semver.dart';
enum UpdateKind { none, optional, mandatory }
class UpdateInfo {
UpdateInfo(this.latest, this.minSupported, this.installerUrl);
final Version latest;
final Version minSupported;
final Uri installerUrl;
}
Future<UpdateInfo> fetchUpdateInfo() async {
final res = await http.get(Uri.parse('https://example.com/version.json'));
final json = jsonDecode(res.body) as Map<String, dynamic>;
return UpdateInfo(
Version.parse(json['latest'] as String),
Version.parse(json['min_supported'] as String),
Uri.parse(json['installer_url'] as String),
);
}
UpdateKind classify(Version current, UpdateInfo info) {
if (current < info.minSupported) return UpdateKind.mandatory;
if (current < info.latest) return UpdateKind.optional;
return UpdateKind.none;
}
Future<void> installUpdate(UpdateInfo info) async {
final dir = await getTemporaryDirectory();
final file = File('${dir.path}\\setup.exe');
await file.writeAsBytes((await http.get(info.installerUrl)).bodyBytes);
// Real code should also verify the download before running it.
// A per-user Inno Setup installer: no UAC prompt, no wizard.
await Process.start(
file.path,
['/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART'],
mode: ProcessStartMode.detached,
);
exit(0); // Windows locks a running .exe, so get out of the way.
}Local data: SQLite, and knowing when not to use it
On Windows, sqflite needs its FFI variant. With sqflite_common_ffi you initialize the FFI database factory once at startup and the rest of the sqflite API stays the same. Asaan Optics pairs it with sqlite3_flutter_libs so the SQLite library ships inside the app. Asaan Rental Pro and Asaan Jewellers keep their whole database in SQLite this way, under the user’s Documents folder.
SQLite does not enforce foreign keys unless you turn them on for each connection, so I enable them when the database opens; Asaan Rental Pro relies on ON DELETE CASCADE. And any change that touches two tables goes in one transaction: when a lease is created or ended, the unit’s Occupied or Available status changes in the same transaction.
Local-first is not always the right answer. The first Asaan Optics desktop version kept a full SQLite database per shop, with a synced flag on every table and a background job pushing rows to the cloud. Concurrent writes produced “database is locked” errors, and every schema change had to be made twice: on the server and in every installed copy. I moved the app to API-first. The server is now the source of truth, and the PC keeps what must survive a dropped connection: a queue of pending writes, and a per-terminal SQLite ledger for shifts and cash-drawer events.
At the other end, Tabarak POS has no database at all: menu, orders and table states are JSON in local preferences, which is enough for one restaurant counter.
Secrets and backups
On Windows, shared_preferences writes a plain JSON file in the user’s AppData folder. That is fine for settings, not for session tokens or keys. In Asaan Optics Desktop Pro, tokens go through flutter_secure_storage, which on Windows is backed by the Credential Manager. The app’s Saudi ZATCA e-invoicing support follows the same rule: each PC keeps its own invoice-stamping key in secure storage, on the machine that signs the invoices.
Backups are the other half. On the web, someone else runs your backups. With local data, one failed hard drive can take a business’s records with it, and users will not open a backup screen on their own. So in Asaan Rental Pro, backup happens to the user:
- The owner signs in to their own Google Drive once, through the browser. The app asks only for the drive.file scope, which covers files the app itself created.
- Every hour while the app is open, it zips the data folder (the database plus tenant documents, agreement scans and payment proofs) and overwrites one backup file on Drive. If the user is not signed in, the run is skipped without a prompt.
- Attached images are copied into the data folder with timestamped names, so one archive holds everything. The stored sign-in token is left out.
- Windows will not let you read a file another process has open, so the backup skips locked files and tries again next hour instead of failing.
- A local restore closes the database before copying files back.
- What I would change: one latest backup protects against a lost laptop, not a mistake made two days ago. Dated copies are the next step.
80 mm paper is not 80 mm of printing
An 80 mm roll does not give you 80 mm of printing. The print head is narrower than the paper, and each printer model leaves slightly different margins. I learned this from a receipt that looked perfect on screen and in the PDF preview, and lost the last digits of every price on paper.
In Tabarak POS, the 80 mm setting now lays the page out at 74 mm and the 58 mm setting at 52 mm, with an extra 3 mm on the right for printers that feed slightly off-center. Asaan Optics works to a 72 mm printable area. A common 203 dpi head has 8 dots per millimeter, so 576 dots is 72 mm and 384 dots is 48 mm, the widths Tabarak’s receipt preview uses for 80 mm and 58 mm. Drivoli Optics prints receipts in four roll formats (57×50, 57×70, 80×70 and 80×80 mm) plus A4 and A5, each with its own side margins.
Height is the second surprise. A receipt has no fixed page length; pick one and you get a cut-off total or a long blank tail. I calculate the height from the content: logo, address and phone lines, the number of items, and any discount and tax lines.
These apps print through the Windows print pipeline: a PDF built with the pdf package and sent with the printing package, so they do not depend on one printer brand’s command set; any printer with a Windows driver can be used.
Arabic and Urdu need extra work. On screen, Flutter’s text engine joins Arabic letters into their correct forms and reorders right-to-left text. The PDF library I used does neither, so names that looked right in the app printed as separated letters in reverse order. Tabarak POS now detects Arabic-script text, reshapes it with arabic_reshaper, converts it to visual order with a bidi step, and embeds Noto Naskh Arabic as a fallback font. Asaan Optics also reshapes Arabic text with arabic_reshaper and Noto Naskh Arabic for its receipts.
Barcode labels are measured in dots
Asaan Optics prints Code128 stock labels on thermal sticker printers, commonly 203 or 300 dpi. A scanner decodes the ratios between bar widths. If the narrowest bar works out to 1.4 printer dots, the printer has to round: some bars become one dot, some two, and the ratios are gone. The label looks fine and still fails to scan.
So the layout is computed in printer dots, not millimeters. Every bar is a whole number of dots at the printer’s resolution, and only then is the layout converted to PDF units. At 203 dpi a two-dot bar is 0.25 mm; at 300 dpi the dot counts differ, so the layout is worked out per resolution.
Practical takeaways
This is the checklist I now start a Windows project with. If you are commissioning shop software, the same points make good questions for your developer: how it installs, how it updates, where the data lives, and what happens when the PC fails.
- Ship an installer from the first release, per-user unless the app truly needs machine-wide access.
- Publish a version file with a minimum supported version before your first breaking backend change, not after.
- For local data, use sqflite_common_ffi, turn on foreign keys and wrap multi-table changes in transactions. Decide which half of offline users need: writing without a connection and reading everything locally are very different amounts of work.
- Keep tokens and keys in secure storage backed by the Windows Credential Manager, not in shared_preferences.
- Make backups automatic and silent, with attachments in the same archive as the database.
- Design receipts to the printable width (72 to 74 mm on 80 mm paper, 48 to 52 mm on 58 mm), size the height from the content, and reshape Arabic and Urdu before it reaches the PDF.
- Lay out barcodes in printer dots.
- A feature that touches hardware is finished when real paper has gone through a real printer, several times.