printly 0.1.0
printly: ^0.1.0 copied to clipboard
Thermal printer SDK for Flutter. Bluetooth Classic + BLE, ESC/POS receipts (text, barcode, QR), and Turkish that prints even on printers that ignore code pages.
0.1.0 — 2026-07-31 #
First release. Bluetooth thermal printing for Flutter, written from scratch in Kotlin, Swift and Dart, with Turkish text that works even on printers that cannot be told about code pages.
Bluetooth #
- Classic (SPP) and BLE in one plugin — Classic on Android, BLE on both platforms. Network transport is not implemented and is not advertised.
BluetoothAdapterStateas a six-value enum stream, not a bool.- Scanning with de-duplication by transport + address, 250 ms emission coalescing, timeouts, and a separate error stream for mid-scan failures.
- Per-device
ConnectionStatestreams plusactiveDeviceStream.connectedmeans ready to print — services discovered and a writable characteristic resolved — not merely linked. - Runtime permissions (Android 12+ and legacy, iOS) behind
PrintlyPermissionStatus, with no third-party permission type in the API. - Last-device persistence and opt-in auto-reconnect.
Printing #
- Fluent
PrintJobbuilder: text, feed, cut, divider, raw bytes, seven barcode symbologies, and QR with automatic module sizing from payload length, error level and paper width. - Turkish through code pages: CP857 primary, Windows-1254 and ISO-8859-9 fallbacks, byte tables derived from the Unicode Consortium mappings and cross-checked against CPython and libiconv.
- 58 mm and 80 mm paper, with characters-per-line and dot width derived from the setting rather than hardcoded at call sites.
Raster — Turkish on any printer #
PrintlyRaster.text()draws glyphs with the platform's own text engine and sends dots, so the printer's character set stops mattering. Some printers ignoreESC toutright; on those this is the only way to printğorİ.PrintlyRaster.image()decodes PNG/JPEG/WebP and fits it to the paper.PrintlyRaster.widgetKey()captures a mountedRepaintBoundary.PrintlyBitmapis immutable, so a logo rendered once is free to reuse; it also keepsPrintJob.bitmap()synchronous and chainable.- Floyd-Steinberg or plain threshold dithering, emitted as
GS v 0bands.
Errors #
- Sealed
PrintlyExceptionhierarchy over aPrintlyErrorCodevocabulary shared byte-for-byte between Dart, Kotlin and Swift. A rawPlatformExceptionnever reaches your code and there is nothing to string-match.
Platform #
- Android: minSdk 24, 16 KB page-size compatible (pure Kotlin, no
.so). - iOS: 13.0+, shipped for both CocoaPods and Swift Package Manager.
- Flutter
>=3.35.3— the first release bundling the Dart 3.9.2 this package requires. 3.35.0 through 3.35.2 ship Dart 3.9.0 and were previously claimed in error, which would have met anyone on them with a resolution failure rather than a clear "unsupported". CI now builds against both this floor and current stable, which is how the mismatch surfaced.
Known limits #
- Verified on one printer (Cashino PTP-II). Broader support is the goal, not a claim.
- Ink coverage is a hardware limit: a broad solid-black area can draw more current than a cheap 5 V head sustains and the printer may cut out with no catchable error. printly reports size and coverage but does not block.
- Bluetooth Classic gives a whole job a single 10-second write budget on Android; a very long raster receipt can exceed it.
Development log #
The sections below record how the package was built, sprint by sprint. They are kept for provenance — every entry above is already covered by one of them.
Sprint 6 — Raster pipeline (2026-07-31) #
Turkish text no longer depends on the printer's character set.
Added
PrintlyRaster— renders to printable dots:text()shapes a string with the platform text engine (wrapping, alignment, weight) — the path that makes Turkish work on printers that ignoreESC t.image()decodes PNG/JPEG/WebP and fits it to the paper.widthis a ceiling, not a stretch: a narrow image keeps its size and the printer centres it rather than being upscaled into a blur.repaintBoundary()/widgetKey()capture a mounted, paintedRepaintBoundary. A widget is not accepted directly because Flutter offers no supported way to render a detached tree — an API that took one would be promising what it cannot do.Offstageand zero-opacity subtrees are rejected with an explanation instead of an engine assert.
PrintlyBitmap— an immutable one-bit image, and the seam between the asynchronous rendering half and the synchronous command half. Rendering a logo once and stamping it onto every receipt costs nothing after the first.toRgba()expands it back to pixels so a preview can show exactly what the head will burn.PrintlyDithering— Floyd-Steinberg (a two-row error buffer rather than a full plane: 1.5 KB instead of 1.5 MB for a receipt) or a plain threshold. Two values, not three: anonemode would be byte-identical tothreshold.PrintJob.bitmap()appends a rendered bitmap synchronously, so it still chains. Alignment goes through the generator's style cache — emitted as raw bytes it would leave the cache stale and the nexttext()would print misaligned.textRaster(),image()andwidget()are awaitable sugar over it, typed so a mistaken cascade is a compile error rather than a scrambled receipt.PrintConfig.rasterBandHeight(default 64 rows). Tall images are split intoGS v 0bands, each about 3 KB at 58 mm, staying under 256 rows so the command's high height byte is always zero — firmware that ignores that byte is a known hazard.
Notes
- The wrapped library's own raster path is not used. For any width that is not
already a multiple of 8 it replaces the pixel data with a zero-filled
fixed-length list and then calls
insertAllon it, so it throws before it can print — and derives its header from the unaligned width regardless. printly emitsGS v 0itself and rounds widths down to a multiple of 8, because rounding up would overflow the head and make the printer wrap. - Transparent pixels composite to white, not black.
dart:uireturns premultiplied alpha, so reading the colour channels of a transparent canvas naively yields black — and a receipt-sized black bitmap would burn a roll. - Text uses a lighter cutoff (176) than images (128). A font rasteriser antialiases, and at the neutral cutoff only the one-dot core of each stroke burns; thermal heads render isolated dots weakly, so ordinary weights came out washed out while bold looked fine. Measured on paper.
- Verified end to end on a Cashino PTP-II — the printer that ignores
ESC tand so could not print Turkish at all before this.
Sprint 5 — iOS Bluetooth print & platform readiness (2026-07-02) #
Added
- iOS ESC/POS printing over BLE.
print()/write()now works on iOS: after the link comes up, printly discovers services, resolves the write characteristic (same preference order as Android:FF02,49535343-8841-…,FFE1, then any writable), and streams the job in single-ATT-packet chunks bounded by a 5 s per-chunk watchdog. Theconnectedstate now means ready-to-print on iOS exactly as it does on Android — it is emitted only after the write characteristic is resolved, and the connect timeout covers service discovery too. Error vocabulary is byte-identical with Android (not_connected,not_ready,write_busy,write_timeout,write_failed). - Swift Package Manager support. iOS sources moved to the SPM layout
(
ios/printly/Sources/printly/) with aPackage.swift; Flutter 3.44+ resolves the plugin via SPM automatically. The.podspecstill points at the same sources, so CocoaPods apps are unaffected. - Android 16 KB page-size compatibility documented: the Android side is pure Kotlin with no bundled native binaries, so the plugin is 16 KB compatible as-is; README explains what consumers need to check.
Changed
- Network (Ethernet/WiFi) printing moved out of the
v0.1.0scope to a post-v1 release; the raster pipeline moved to Sprint 6 (still pre-v1).PrintlyDevice.network(...)andConnectionType.networkremain in the API and keep rejecting withnetwork_not_supported.
Hardening & deep-review fixes (2026-07-02) #
A six-dimension adversarially-verified code review (69 findings) was applied
across all three layers, verified end-to-end (flutter analyze clean,
151 tests, Android APK + iOS build green).
Fixed
- Persistence: the last-connected device is now persisted on every
successful
connect()(previously only after a store-touching API call), and a persisted auto-reconnect flag re-arms its adapter listener on the next launch — both "remember my printer" features actually work now. - Dart races:
stopScan(); startScan();(the natural rescan gesture) is queued instead of silently swallowed; a staledisconnectedevent from a torn-down link no longer rejects a freshconnect()to the same device. - QR: payloads over 252 bytes emitted a corrupt
GS ( kstore command (the wrapped library hardcodespH=0); printly now emits the QR function sequence itself with correct two-byte length math. Payloads beyond the QR byte-mode maximum (2953) throwArgumentError. - Barcodes: literal
{in CODE128 payloads is escaped ({{) and caller-supplied{A/{B/{Cselectors are honoured; module width is clamped to the spec range 2–6 and emitted on every barcode so one barcode's width never leaks into the next. - Failed-future caching: a failed
CapabilityProfile.load()orLastDeviceStore.open()no longer bricksnewJob()/persistence for the rest of the session — failures are evicted and retried. - Android: scanning with Bluetooth off now rejects with
bluetooth_not_powered_on(was a silent, empty 30 s scan); GATT callbacks are main-thread-confined (removes watchdog/retry data races); aconnectGattearly-failure race that could leak a GATT client slot is closed and anullreturn fails fast; user-initiated disconnects no longer terminate inerror;connect()during teardown rejects withdisconnect_in_progressinstead of re-emitting a falseconnected; Classicwrite()is gated on connection state (its watchdog could kill an in-flight connect); Classic connect-timeout reason aligned toconnect_timeout;write_timeout/disconnectednow reach Dart as their own error codes. - iOS: the first
startScan()/connect()after launch no longer fails deterministically (CBCentralManageroperations queue until the firstdidUpdateState); connect-timeout work items are cancelled on terminal states (a stale timeout could abort a later attempt to the same peripheral); duplicateconnect()re-emits the effective state so a hot-restarted Dart side rehydrates (Android parity);disconnect()during a pending connect reaps synchronously and established links get a 4 s fallback reaper;detachFromEngineimplemented (native resources no longer outlive the engine); the privateApp-Prefs:URL scheme removed (App Store 2.5.1 risk — app settings page is opened instead);.unauthorizedsurfaces aspermission_denied(was a misleadingbluetooth_not_powered_on).
Added
- Typed error model: sealed
PrintlyExceptionhierarchy (PrintlyScanException,PrintlyConnectionExceptionwith aTimeoutException-compatible timeout subtype,PrintlyWriteException,PrintlyPermissionException,PrintlyUnsupportedException) carrying a cross-platformPrintlyErrorCode;PlatformExceptions are mapped at the method channel, so consumers switch on codes instead of parsing strings. PrintlyPermissionStatus— printly's own enum;permission_handler'sPermissionStatusno longer leaks into (or is re-exported from) the public API.Printly.scanErrorsStream— mid-scan native failures (Bluetooth toggled off, scan-failed callbacks) are surfaced instead of looking like a normal timeout stop.- CP437 table for
PrintlyCharset.latin: é ü ç ö £ ° and the rest of the CP437 repertoire now print instead of?(only ğ Ğ ı İ ş Ş remain unrepresentable on that page). - Android BLE MTU negotiation (
requestMtu(517), chunking at MTU−3,WRITE_TYPE_NO_RESPONSEpreferred when supported) — the throughput prerequisite for the upcoming raster sprint. - Wire-protocol strings single-sourced per platform (
WireProtocolin Dart,WireCodesin Kotlin and Swift) — channel/method/key/error strings can no longer drift silently at individual call sites. - iOS
PERMISSION_BLUETOOTH=1Podfile requirement documented in the README and applied to the example app (without itrequestPermissions()always reportedpermanentlyDeniedon iOS). - Exhaustive code-page table tests (every defined byte of CP437/CP857/
Windows-1254/ISO-8859-9 round-trips), a golden receipt byte-stream test
pinning the wire format against dependency upgrades, GS
!size-operand and style-reset tests, facade persistence/auto-reconnect regression tests, and typed-error mapping tests. 151 tests total.
Changed
Printly.connect()fails fast withPrintlyUnsupportedExceptionforConnectionType.networkdevices until the network transport lands.requestPermissions()returnsPrintlyPermissionStatus.- The speculative
wireCode/fromWireCodeAPI was removed from the nine print-layer enums (nothing crosses the wire there; the connection/adapter enums keep theirs). - Unused
image,mockito, andbuild_runnerdependencies removed; the Flutter lower bound corrected to>=3.35.0to matchsdk: ^3.9.2. - LICENSE set to MIT. CI now compiles both native layers (example APK +
iOS build), runs the example tests, and the publish dry-run gate is no
longer advisory (
continue-on-errorremoved). QrSizingexported alongsideTurkishCodePageas stable utilities.- Docs honesty pass: pubspec/podspec/README no longer advertise
unimplemented Ethernet/WiFi;
ConnectionState.reconnectingdocumented as reserved (never emitted today); barcode GS k function A/B docs corrected;raw()documents its style-cache caveat.
Sprint 4 — ESC/POS core & Turkish charset #
- Print-layer enums (all
Printly-prefixed to avoid clashes withdart:ui/Material):PrintlyPaperWidth(58 mm = 384 dots, 80 mm = 576 dots),PrintlyTextAlign,PrintlyTextStyle,PrintlyTextSize,PrintlyCutMode,PrintlyBarcodeType,PrintlyCharset,PrintlyQrErrorLevel,PrintlyHriPosition. TurkishCodePage.encode()— a dedicated CP857 (primary) / Windows-1254 / ISO-8859-9 encoder. The byte tables were derived from the Unicode Consortium mappings and cross-checked against the CPythoncp857/cp1254codecs and libiconv; they correct the inaccurate example table in the roadmap (e.g.ğ→0xA7,ş→0x9F,Ş→0x9E).ESC tselectors corrected to CP857 = 13 and WPC1254 = 48.PrintConfigand the fluentPrintJobbuilder (text,feed,cut,divider,raw,barcode,qr), layered onesc_pos_utils_plusfor command framing while overriding only the Turkish text encoding viatextEncoded.- 1-D barcodes for 7 symbologies (EAN-13/8, UPC-A, CODE39, CODE128, ITF, CODABAR) with automatic CODE128
{Bcode-set prefixing and HRI controls. - Smart QR sizing (
QrSizing): estimates the symbol version from payload length + error level and picks the largest module dot size that fits the paper, with an optionalmaxModuleSizecap. Printly.instance.newJob(...)andprint(device, job), plus a newwrite({device, bytes})platform method.- Android native write path: RFCOMM
OutputStreamwrites for Classic, and GATT service discovery + ack-gated chunked characteristic writes (API 33+ and legacy paths) for BLE. iOSwriterejects withunsupported_platformpending Sprint 6. - Example app: 58/80 mm paper-width selector and a Turkish test-receipt button (text + QR + barcode).
Sprint 3 — Device discovery & connection #
- Core models:
ConnectionType,ConnectionState,PrintlyDevice, andPrintlyConnectionEventwith stable integer wire codes shared across Dart/Kotlin/Swift. ScanControllerwith re-entrancy guards (concurrentstartScancalls share one native scan), dedup/merge by transport + address, and automatic timeout.ConnectionControllerwith per-device state streams,activeDeviceStream, serialised device switching (disconnect previous → connect new), and failure-reason tracking.- Persistence via
LastDeviceStore(SharedPreferences) for the last-connected device and an opt-in auto-reconnect flag. - Public API on
Printly.instance:startScan,stopScan,devicesStream,connect,disconnect,connectionStateOf,activeDeviceStream,loadLastConnectedDevice,reconnectLastDevice,enableAutoReconnect. - Android native split into layered files (adapter/scan/connection/util) with modern, non-deprecated APIs:
BluetoothLeScanner,BluetoothDevice.TRANSPORT_LE, API 33+getParcelableExtraoverloads. Classic RFCOMM connects run on a dedicated IO thread with socket-close based timeout. - iOS native split around a shared
CentralControllerthat owns a singleCBCentralManager, so the Bluetooth permission prompt only appears once. BLE scan + connect implemented; Classic rejects withclassic_requires_mfi; network deferred. - Example app extended with start/stop scan, discovered devices list with per-device connect/disconnect buttons, and active-device banner.
Sprint 2 — Bluetooth state & permissions #
BluetoothAdapterStateenum with 6 values and stable integer wire codes.- Live adapter state stream (
Printly.instance.adapterState) backed by a broadcast receiver on Android and a lazily instantiatedCBCentralManageron iOS. - Cached
currentAdapterStateand synchronousisBluetoothAvailablegetter viaBluetoothManager. requestPermissions()with platform-specific flows (Android 12+ vs 11-, iOS) and aggregatedPermissionStatusresult.openBluetoothSettings()andopenAppSettings()helpers.- Example app rewritten around a Bluetooth playground page.
Sprint 1 — Foundation & scaffold #
- Package scaffold, CI (analyze + test + publish dry-run), and strict lint baseline.