The Approach
The app is deliberately split in two. Everything that is user interface – list management, import, queue progress, settings – runs in Flutter. Everything that touches telephony sits natively in Android. The seam between the two worlds is made of method channels, and it is designed to be asymmetric: commands travel from Dart into the native layer, state changes come back as a stream of events. That keeps the interface small and makes it the single place where both sides have to agree on anything.
The split is not arbitrary. In an app like this, the interface is the larger part and the part that gets iterated on most – exactly where Flutter plays to its strengths. The telephony part is small but sensitive: the relevant Android APIs have changed several times across versions, for instance the replacement of the classic PhoneStateListener with TelephonyCallback from Android 12 onward, and the rules around telephony permissions are tightened regularly. Changes like that are easier to absorb in a small, isolated Kotlin layer than spread across the whole app.
Processing itself is a state machine on the native side: idle, dialing, in call, post-call, next entry or pause. Advancing is driven not by timers but by the call states the system reports. In practice that is the decisive difference: a time-driven dialer falls out of step as soon as a call runs long, is answered late or ends up in voicemail. An event-driven one simply waits until the call state returns to idle.
The input side stays deliberately simple: numbers come from the device contacts or from a CSV file. Normalizing them into a single dialable format – leading zero, country code, spaces and punctuation – belongs in the import, not in a running series of calls. Anything that makes it into the queue should be dialable. And because automation that cannot be stopped is unusable in daily operation, the current entry stays visible at all times and processing can be interrupted at any point.