@_nazmiforreal/flutter-ota 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/bin/flutter-ota-linux-x64 +0 -0
  2. package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +7 -7
  3. package/dart-src/packages/cli-tools/lib/flutter_ota_kit_cli.dart +200 -0
  4. package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +29 -65
  5. package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +4 -15
  6. package/dart-src/packages/cli-tools/lib/src/commands/config_command.dart +2 -12
  7. package/dart-src/packages/cli-tools/lib/src/commands/console.dart +3 -1
  8. package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +62 -24
  9. package/dart-src/packages/cli-tools/lib/src/commands/fingerprint.dart +3 -4
  10. package/dart-src/packages/cli-tools/lib/src/commands/init.dart +19 -47
  11. package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +12 -13
  12. package/dart-src/packages/cli-tools/lib/src/commands/pocketbase.dart +2 -0
  13. package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +15 -17
  14. package/dart-src/packages/cli-tools/lib/src/commands/storage.dart +14 -16
  15. package/dart-src/packages/cli-tools/lib/src/operations.dart +9 -0
  16. package/dart-src/packages/cli-tools/lib/src/pocketbase/installer.dart +0 -1
  17. package/dart-src/packages/cli-tools/lib/src/pocketbase/schema_installer.dart +0 -3
  18. package/dart-src/packages/cli-tools/lib/src/runner.dart +15 -1
  19. package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +126 -8
  20. package/dart-src/packages/cli-tools/pubspec.lock +7 -7
  21. package/dart-src/packages/core/CHANGELOG.md +1 -12
  22. package/dart-src/packages/core/LICENSE +1 -1
  23. package/dart-src/packages/core/pubspec.yaml +1 -1
  24. package/dart-src/plugins/aws/CHANGELOG.md +1 -18
  25. package/dart-src/plugins/aws/LICENSE +1 -1
  26. package/dart-src/plugins/aws/pubspec.yaml +1 -1
  27. package/dart-src/plugins/cloudflare/CHANGELOG.md +1 -18
  28. package/dart-src/plugins/cloudflare/LICENSE +1 -1
  29. package/dart-src/plugins/cloudflare/pubspec.yaml +1 -1
  30. package/dart-src/plugins/plugin-core/CHANGELOG.md +1 -17
  31. package/dart-src/plugins/plugin-core/LICENSE +1 -1
  32. package/dart-src/plugins/plugin-core/pubspec.yaml +1 -1
  33. package/dart-src/plugins/pocketbase/CHANGELOG.md +1 -8
  34. package/dart-src/plugins/pocketbase/LICENSE +1 -1
  35. package/dart-src/plugins/pocketbase/pubspec.yaml +1 -1
  36. package/dart-src/plugins/postgres/CHANGELOG.md +1 -19
  37. package/dart-src/plugins/postgres/LICENSE +1 -1
  38. package/dart-src/plugins/postgres/pubspec.yaml +1 -1
  39. package/dart-src/plugins/supabase/CHANGELOG.md +1 -23
  40. package/dart-src/plugins/supabase/LICENSE +1 -1
  41. package/dart-src/plugins/supabase/pubspec.yaml +1 -1
  42. package/package.json +1 -1
  43. package/scripts/vendor.sh +0 -0
@@ -248,10 +248,8 @@ class InitCommand extends FlutterPatcherCommand {
248
248
  step('Wrote ${file.path}');
249
249
 
250
250
  if (global) {
251
- stdout.writeln(
252
- ' ${yellow('⚠')} Secrets are stored in plaintext under ~/.flutter_ota_kit. '
253
- 'Restrict permissions and use a secrets manager in production.',
254
- );
251
+ warn('Secrets are stored in plaintext under ~/.flutter_ota_kit. '
252
+ 'Restrict permissions and use a secrets manager in production.');
255
253
  return;
256
254
  }
257
255
 
@@ -259,35 +257,23 @@ class InitCommand extends FlutterPatcherCommand {
259
257
  await _scaffoldProject(cfg, provider);
260
258
 
261
259
  stdout.writeln();
262
- stdout.writeln(cyan('Next steps:'));
263
- stdout.writeln(' 1. ${dim('flutter pub get')}');
264
- stdout.writeln(
265
- ' 2. In lib/main.dart call ${green('await setupFlutterOta();')} '
266
- 'right after WidgetsFlutterBinding.ensureInitialized() (before runApp).',
267
- );
268
- stdout.writeln(
269
- ' A `.env` scaffold was written — put secrets there, then build with '
270
- '`--dart-define-from-file=.env` (environment overrides config).',
271
- );
260
+ stdout.writeln(' ${cyan('Next steps:')}');
261
+ step('1. ${dim('flutter pub get')}');
262
+ step('2. In lib/main.dart call ${green('await setupFlutterOta();')} '
263
+ 'right after WidgetsFlutterBinding.ensureInitialized() (before runApp). '
264
+ 'A `.env` scaffold was written — put secrets there, then build with '
265
+ '`--dart-define-from-file=.env` (environment overrides config).');
272
266
  if (provider == 'supabase') {
273
- stdout.writeln(
274
- ' 3. Provision the backend once: ${dim('flutter-ota migrate supabase')}',
275
- );
267
+ step('3. Provision the backend once: ${dim('flutter-ota migrate supabase')}');
276
268
  } else if (provider == 'postgres') {
277
- stdout.writeln(
278
- ' 3. Provision the backend: ${dim('flutter-ota migrate postgres')}',
279
- );
269
+ step('3. Provision the backend: ${dim('flutter-ota migrate postgres')}');
280
270
  } else if (provider == 'pocketbase') {
281
- stdout.writeln(
282
- ' 3. Install PocketBase + provision the schema: '
283
- '${dim('flutter-ota pocketbase install')} then '
284
- '${dim('flutter-ota pocketbase serve')}.',
285
- );
271
+ step('3. Install PocketBase + provision the schema: '
272
+ '${dim('flutter-ota pocketbase install')} then '
273
+ '${dim('flutter-ota pocketbase serve')}.');
286
274
  }
287
- stdout.writeln(
288
- ' 4. Build a patch: ${dim('flutter-ota build --name 1.0.1')} '
289
- 'then ${dim('flutter-ota deploy')}',
290
- );
275
+ step('4. Build a patch: ${dim('flutter-ota build --name 1.0.1')} '
276
+ 'then ${dim('flutter-ota deploy')}');
291
277
  });
292
278
 
293
279
  /// Generate the integration files (pubspec dep, manifest permission, setup dart
@@ -306,9 +292,7 @@ class InitCommand extends FlutterPatcherCommand {
306
292
  void _addPubDependency() {
307
293
  final pubspec = File('pubspec.yaml');
308
294
  if (!pubspec.existsSync()) {
309
- stdout.writeln(
310
- yellow(' ⚠ pubspec.yaml not found; skipping dependency injection.'),
311
- );
295
+ warn('pubspec.yaml not found; skipping dependency injection.');
312
296
  return;
313
297
  }
314
298
  var content = pubspec.readAsStringSync();
@@ -320,11 +304,7 @@ class InitCommand extends FlutterPatcherCommand {
320
304
  final marker = '\ndependencies:';
321
305
  final idx = content.indexOf(marker);
322
306
  if (idx == -1) {
323
- stdout.writeln(
324
- yellow(
325
- ' ⚠ could not find a `dependencies:` block; add `flutter_ota_kit` manually.',
326
- ),
327
- );
307
+ warn('Could not find a `dependencies:` block; add `flutter_ota_kit` manually.');
328
308
  return;
329
309
  }
330
310
  final insertAt = idx + marker.length;
@@ -336,11 +316,7 @@ class InitCommand extends FlutterPatcherCommand {
336
316
  void _addInternetPermission() {
337
317
  final mf = File('android/app/src/main/AndroidManifest.xml');
338
318
  if (!mf.existsSync()) {
339
- stdout.writeln(
340
- yellow(
341
- ' ⚠ AndroidManifest.xml not found; skipping INTERNET permission.',
342
- ),
343
- );
319
+ warn('AndroidManifest.xml not found; skipping INTERNET permission.');
344
320
  return;
345
321
  }
346
322
  var content = mf.readAsStringSync();
@@ -350,11 +326,7 @@ class InitCommand extends FlutterPatcherCommand {
350
326
  }
351
327
  final match = RegExp(r'<manifest[^>]*>').firstMatch(content);
352
328
  if (match == null) {
353
- stdout.writeln(
354
- yellow(
355
- ' ⚠ could not locate <manifest> tag; add INTERNET permission manually.',
356
- ),
357
- );
329
+ warn('Could not locate <manifest> tag; add INTERNET permission manually.');
358
330
  return;
359
331
  }
360
332
  final insertAt = match.end;
@@ -1,7 +1,5 @@
1
1
  import 'dart:convert';
2
2
 
3
-
4
- import '../backend.dart';
5
3
  import '../cli_base.dart';
6
4
  import '../config.dart';
7
5
  import '../sign.dart';
@@ -9,13 +7,10 @@ import '../ui/ui.dart';
9
7
 
10
8
  /// `flutter_ota_kit keys` — generate an Ed25519 keypair for bundle signing.
11
9
  class KeysCommand extends FlutterPatcherCommand {
12
- KeysCommand({this.config, this.backendOverride}) {
10
+ KeysCommand() {
13
11
  argParser.addFlag('save', help: 'Persist the public key into the project config.');
14
12
  }
15
13
 
16
- final FlutterPatcherConfig? config;
17
- final Backend? backendOverride;
18
-
19
14
  @override
20
15
  String get name => 'keys';
21
16
 
@@ -26,10 +21,11 @@ class KeysCommand extends FlutterPatcherCommand {
26
21
  @override
27
22
  Future<int> run() => runGuarded(() async {
28
23
  banner('keys');
29
- final (privateB64, publicB64) = await spinner(
30
- () => generateEd25519KeyPair(),
24
+ final steps = Steps('keys');
25
+ final (privateB64, publicB64) = await steps.run(
31
26
  'Generating Ed25519 keypair',
32
- done: 'Keypair generated',
27
+ () => generateEd25519KeyPair(),
28
+ // Use Steps.run without the spinner pattern; keys is fast.
33
29
  );
34
30
  box('ed25519 keys', [
35
31
  '${bold('private')} (keep secret — use with `deploy --key`):',
@@ -42,12 +38,15 @@ class KeysCommand extends FlutterPatcherCommand {
42
38
  final file = configCandidates().first;
43
39
  final json =
44
40
  file.existsSync() && file.readAsStringSync().trim().isNotEmpty
45
- ? (jsonDecode(file.readAsStringSync()) as Map<String, dynamic>)
46
- : <String, dynamic>{};
41
+ ? (jsonDecode(file.readAsStringSync()) as Map<String, dynamic>)
42
+ : <String, dynamic>{};
47
43
  writePath(json, 'publicKey', publicB64);
48
- _save(json);
49
- step('Saved public key to ${file.path}');
44
+ await steps.run<void>('Saving public key to ${file.path}',
45
+ () async {
46
+ _save(json);
47
+ });
50
48
  }
49
+ steps.summary();
51
50
  });
52
51
 
53
52
  void _save(Map<String, dynamic> json) {
@@ -202,6 +202,7 @@ class PocketBaseStopCommand extends FlutterPatcherCommand {
202
202
 
203
203
  @override
204
204
  Future<int> run() => runGuarded(() async {
205
+ banner('pocketbase · stop');
205
206
  step('Use Ctrl+C on the running serve process to stop PocketBase.');
206
207
  return;
207
208
  });
@@ -216,6 +217,7 @@ class PocketBaseStatusCommand extends FlutterPatcherCommand {
216
217
 
217
218
  @override
218
219
  Future<int> run() => runGuarded(() async {
220
+ banner('pocketbase · status');
219
221
  final paths = PocketBaseInstallPaths.resolve();
220
222
  final exists = await paths.binaryPath.exists();
221
223
  if (exists) {
@@ -45,23 +45,21 @@ class RollbackCommand extends FlutterPatcherCommand {
45
45
  final backend = requireBackend(cfg, override: backendOverride);
46
46
 
47
47
  banner('rollback');
48
- final (disabled, live) = await spinner(
49
- () async {
50
- if (bundleId != null && bundleId.isNotEmpty) {
51
- return rollbackToBundle(
52
- backend,
53
- channel,
54
- bundleId,
55
- platform: platform,
56
- );
57
- }
58
- final id = await rollbackChannel(backend, channel);
59
- final nowLive = (await getChannel(backend, channel))?.id ?? '';
60
- return (<String>[id], nowLive);
61
- },
62
- 'Rolling back channel "$channel"',
63
- done: 'Rolled back',
64
- );
48
+ final steps = Steps('rollback');
49
+ final (disabled, live) = await steps.run('Rolling back channel "$channel"', () async {
50
+ if (bundleId != null && bundleId.isNotEmpty) {
51
+ return rollbackToBundle(
52
+ backend,
53
+ channel,
54
+ bundleId,
55
+ platform: platform,
56
+ );
57
+ }
58
+ final id = await rollbackChannel(backend, channel);
59
+ final nowLive = (await getChannel(backend, channel))?.id ?? '';
60
+ return (<String>[id], nowLive);
61
+ });
62
+ steps.summary();
65
63
  box('rollback', [
66
64
  kv('channel', channel),
67
65
  kv('now live', live.isEmpty ? dim('(none)') : cyan(live)),
@@ -46,11 +46,13 @@ class StorageListCommand extends FlutterPatcherCommand {
46
46
  final backend = requireBackend(cfg, override: backendOverride);
47
47
  final prefix = argResults!['prefix'] as String?;
48
48
  banner('storage · list');
49
- final objects = await backend.storage.listObjects(
50
- prefix == null || prefix.isEmpty ? null : prefix,
51
- );
49
+ final steps = Steps('list');
50
+ final objects = await steps.run('Listing storage objects', () =>
51
+ backend.storage.listObjects(
52
+ prefix == null || prefix.isEmpty ? null : prefix));
52
53
  if (objects.isEmpty) {
53
- step('(no objects)');
54
+ steps.skip('(no objects)');
55
+ steps.summary();
54
56
  return;
55
57
  }
56
58
  final rows = <List<String>>[];
@@ -58,11 +60,12 @@ class StorageListCommand extends FlutterPatcherCommand {
58
60
  final size = o.size >= 1024 * 1024
59
61
  ? '${(o.size / (1024 * 1024)).toStringAsFixed(2)} MB'
60
62
  : o.size >= 1024
61
- ? '${(o.size / 1024).toStringAsFixed(1)} KB'
62
- : '${o.size} B';
63
+ ? '${(o.size / 1024).toStringAsFixed(1)} KB'
64
+ : '${o.size} B';
63
65
  rows.add([cyan(o.key), size]);
64
66
  }
65
67
  table('${objects.length} objects', ['KEY', 'SIZE'], rows);
68
+ steps.summary();
66
69
  });
67
70
  }
68
71
 
@@ -96,18 +99,13 @@ class StorageDeleteCommand extends FlutterPatcherCommand {
96
99
  final cfg = effectiveConfig(config ?? loadConfig(), argResults!);
97
100
  final backend = requireBackend(cfg, override: backendOverride);
98
101
  banner('storage · delete');
102
+ final steps = Steps('delete');
99
103
  if (keys.isNotEmpty) {
100
- await spinner(
101
- () => backend.storage.deleteObjects(keys),
102
- 'Deleting ${keys.length} object(s)',
103
- done: 'Deleted',
104
- );
104
+ await steps.run('Deleting ${keys.length} object(s)',
105
+ () => backend.storage.deleteObjects(keys));
105
106
  } else {
106
- await spinner(
107
- () => backend.storage.delete(uri!),
108
- 'Deleting $uri',
109
- done: 'Deleted',
110
- );
107
+ await steps.run('Deleting $uri', () => backend.storage.delete(uri!));
111
108
  }
109
+ steps.summary();
112
110
  });
113
111
  }
@@ -23,6 +23,7 @@ class DeployOptions {
23
23
  this.gitCommitHash,
24
24
  this.bundleId,
25
25
  this.metadata,
26
+ this.onPhase,
26
27
  });
27
28
 
28
29
  final String source;
@@ -36,6 +37,10 @@ class DeployOptions {
36
37
  final String? gitCommitHash;
37
38
  final String? bundleId;
38
39
  final BundleMetadata? metadata;
40
+
41
+ /// Called with a human-readable phase label as deploy progresses.
42
+ /// Used by the CLI to show per-step progress (e.g. Steps.run()).
43
+ final void Function(String phase)? onPhase;
39
44
  }
40
45
 
41
46
  /// Options for [listBundles].
@@ -85,6 +90,7 @@ Future<Bundle> deployBundle(Backend backend, DeployOptions opts) async {
85
90
  zipPath = packaged.path;
86
91
  shouldDelete = false;
87
92
  } else {
93
+ opts.onPhase?.call('Zipping source');
88
94
  zipPath = await zipDirectory(opts.source);
89
95
  shouldDelete = true;
90
96
  }
@@ -94,6 +100,7 @@ Future<Bundle> deployBundle(Backend backend, DeployOptions opts) async {
94
100
  final zipBytes = await File(zipPath).readAsBytes();
95
101
  // Device SDK verifies the artifact against an MD5 hex of the whole file,
96
102
  // so `fileHash` is always the MD5 hex (never a `sig:`-prefixed value).
103
+ opts.onPhase?.call('Hashing artifact');
97
104
  final md5Hex = md5.convert(zipBytes).toString();
98
105
  final signature = opts.signingKeyBase64 != null
99
106
  ? await ed25519Sign(utf8.encode(md5Hex), opts.signingKeyBase64!)
@@ -101,6 +108,7 @@ Future<Bundle> deployBundle(Backend backend, DeployOptions opts) async {
101
108
 
102
109
  final bundleId = opts.bundleId ?? uuidV7();
103
110
  final key = bundleId;
111
+ opts.onPhase?.call('Uploading to storage');
104
112
  final uploaded = await backend.storage.upload(key, zipPath);
105
113
  final storageUri = uploaded['storageUri'];
106
114
  if (storageUri == null || storageUri.isEmpty) {
@@ -125,6 +133,7 @@ Future<Bundle> deployBundle(Backend backend, DeployOptions opts) async {
125
133
  rolloutCohortCount: defaultRolloutCohortCount,
126
134
  );
127
135
 
136
+ opts.onPhase?.call('Registering bundle in database');
128
137
  await backend.db.appendBundle(bundle);
129
138
  await backend.db.commitBundle();
130
139
  return bundle;
@@ -9,7 +9,6 @@
9
9
  /// windows/amd64. Other combinations fall back to a helpful error.
10
10
  library;
11
11
 
12
- import 'dart:async';
13
12
  import 'dart:io';
14
13
 
15
14
  import 'package:archive/archive.dart';
@@ -232,6 +232,3 @@ class _CollectionSpec {
232
232
  final String name;
233
233
  final String schema;
234
234
  }
235
-
236
- /// Re-export so callers can use the typed client as well.
237
- typedef PocketBaseBackend = PocketBaseClient;
@@ -18,8 +18,22 @@ class FlutterPatcherRunner extends CommandRunner<int> {
18
18
  }
19
19
 
20
20
  @override
21
- Future<int?> runCommand(ArgResults results) {
21
+ Future<int?> runCommand(ArgResults results) async {
22
22
  verboseMode = results['verbose'] as bool? ?? false;
23
+
24
+ // Intercept missing subcommand for parent commands so we show our
25
+ // colored usage text instead of the args package's plain text.
26
+ if (results.command == null && results.rest.isEmpty) {
27
+ final cmdName = results.arguments.isNotEmpty ? results.arguments.first : null;
28
+ if (cmdName != null) {
29
+ final cmd = commands[cmdName];
30
+ if (cmd != null && cmd.subcommands.isNotEmpty) {
31
+ cmd.printUsage();
32
+ return 0;
33
+ }
34
+ }
35
+ }
36
+
23
37
  return super.runCommand(results);
24
38
  }
25
39
 
@@ -99,6 +99,8 @@ void banner(String name) {
99
99
  void step(String msg) => _echo(' ${green('✓')} $msg');
100
100
  void warn(String msg) => _echo(' ${yellow('⚠')} ${yellow(msg)}');
101
101
  void err(String msg) => _echo(' ${red('✗')} ${red(msg)}');
102
+ void info(String msg) => _echo(' ${blue('ℹ')} $msg');
103
+ void active(String msg) => _echo(' ${cyan('⠹')} $msg');
102
104
 
103
105
  // ── Spinner ──────────────────────────────────────────────────────────────────
104
106
 
@@ -116,13 +118,18 @@ Future<T> spinner<T>(
116
118
  return r;
117
119
  }
118
120
  var i = 0;
121
+ final sw = Stopwatch()..start();
119
122
  final timer = Timer.periodic(const Duration(milliseconds: 80), (_) {
120
123
  i = (i + 1) % _frames.length;
121
124
  stdout.write('\r\x1b[K ${cyan(_frames[i])} $label');
122
125
  });
123
126
  try {
124
127
  final r = await task();
128
+ // Keep spinner visible for at least 400ms so fast ops are readable.
129
+ final elapsed = sw.elapsedMilliseconds;
130
+ if (elapsed < 400) await Future.delayed(Duration(milliseconds: 400 - elapsed));
125
131
  timer.cancel();
132
+ sw.stop();
126
133
  stdout.write('\r\x1b[K ${green('✓')} ${done ?? label}\n');
127
134
  return r;
128
135
  } catch (e) {
@@ -232,12 +239,12 @@ void box(String title, List<String> lines) {
232
239
  final leftDashes = math.max(1, totalDashes ~/ 2);
233
240
  final rightDashes = totalDashes - leftDashes;
234
241
 
235
- _echo('╭${'─' * leftDashes}$titleText${'─' * rightDashes}╮');
242
+ _echo('┌${'─' * leftDashes}$titleText${'─' * rightDashes}┐');
236
243
  for (final l in wrapped) {
237
244
  final pad = math.max(0, inner - _dispWidth(l));
238
245
  _echo('│ $l${' ' * pad} │');
239
246
  }
240
- _echo('╰${'─' * (width - 2)}╯');
247
+ _echo('└${'─' * (width - 2)}┘');
241
248
  }
242
249
 
243
250
  // ── Steps ────────────────────────────────────────────────────────────────────
@@ -295,18 +302,122 @@ class Steps {
295
302
  final parts = <String>[];
296
303
  if (_done > 0) parts.add('${_done} completed');
297
304
  if (_skipped > 0) parts.add('${_skipped} skipped');
298
- if (_failed > 0) parts.add('${red('${_failed} error${_failed > 1 ? 's' : ''}')}');
305
+ if (_failed > 0) {
306
+ parts.add('${red('${_failed} error${_failed > 1 ? 's' : ''}')}');
307
+ }
299
308
  if (parts.isEmpty) parts.add('no steps');
300
309
  parts.add(time);
301
310
  box('summary', [parts.join(' · ')]);
302
311
  }
312
+
313
+ /// Run a step that shows a spinner while working, then marks it
314
+ /// success/fail/skip based on the result. This is the recommended way to
315
+ /// show multi-phase work where each phase has a distinct label the user
316
+ /// can understand.
317
+ ///
318
+ /// Example:
319
+ /// ```dart
320
+ /// final s = Steps('deploy');
321
+ /// final zipped = await s.run('Zipping source', () async {
322
+ /// return await zipDir(source);
323
+ /// });
324
+ /// final uploaded = await s.run('Uploading to storage', () async {
325
+ /// return await backend.putObject(bucket, zipped);
326
+ /// });
327
+ /// await s.run('Registering bundle', () async {
328
+ /// return await backend.insertBundle(...);
329
+ /// });
330
+ /// s.summary();
331
+ /// ```
332
+ Future<T> run<T>(String label, Future<T> Function() task) async {
333
+ if (!_colorOn) {
334
+ _echo(' ${dim('•')} $label');
335
+ try {
336
+ final r = await task();
337
+ _done++;
338
+ _echo(' ${green('✓')} $label');
339
+ return r;
340
+ } catch (e) {
341
+ _failed++;
342
+ _echo(' ${red('✗')} $label');
343
+ rethrow;
344
+ }
345
+ }
346
+ var i = 0;
347
+ final sw = Stopwatch()..start();
348
+ final timer = Timer.periodic(const Duration(milliseconds: 80), (_) {
349
+ i = (i + 1) % _frames.length;
350
+ stdout.write('\r\x1b[K ${cyan(_frames[i])} $label');
351
+ });
352
+ try {
353
+ final r = await task();
354
+ final elapsed = sw.elapsedMilliseconds;
355
+ if (elapsed < 200) {
356
+ await Future.delayed(Duration(milliseconds: 200 - elapsed));
357
+ }
358
+ timer.cancel();
359
+ sw.stop();
360
+ stdout.write('\r\x1b[K ${green('✓')} $label\n');
361
+ _done++;
362
+ return r;
363
+ } catch (e) {
364
+ timer.cancel();
365
+ stdout.write('\r\x1b[K ${red('✗')} $label\n');
366
+ _failed++;
367
+ rethrow;
368
+ }
369
+ }
370
+
371
+ Timer? _activeTimer;
372
+ int _activeIdx = 0;
373
+ String? _activeLabel;
374
+
375
+ /// Print an "active" phase line (⠹ label) that will be replaced in-place
376
+ /// by the next call to [startActive] or finalized by [completeActive].
377
+ /// Use this when a long-running operation reports multiple internal phases
378
+ /// via a callback (e.g. `DeployOptions.onPhase`).
379
+ void startActive(String label) {
380
+ _activeLabel = label;
381
+ if (!_colorOn) {
382
+ _echo(' ${dim('•')} $label');
383
+ return;
384
+ }
385
+ _activeIdx = 0;
386
+ _activeTimer?.cancel();
387
+ _activeTimer = Timer.periodic(const Duration(milliseconds: 80), (_) {
388
+ _activeIdx = (_activeIdx + 1) % _frames.length;
389
+ stdout.write('\r\x1b[K ${cyan(_frames[_activeIdx])} $label');
390
+ });
391
+ }
392
+
393
+ /// Finalize the current active line. If [success] is true (default) the
394
+ /// line becomes `✓ label` and counts toward `_done`. If false, it becomes
395
+ /// `✗ label` and counts toward `_failed`. Call this when the long-running
396
+ /// operation finishes.
397
+ void completeActive({bool success = true}) {
398
+ final label = _activeLabel;
399
+ if (label == null) return;
400
+ _activeTimer?.cancel();
401
+ _activeTimer = null;
402
+ _activeLabel = null;
403
+ if (_colorOn) {
404
+ stdout.write(
405
+ '\r\x1b[K ${success ? green('✓') : red('✗')} $label\n',
406
+ );
407
+ }
408
+ if (success) {
409
+ _done++;
410
+ } else {
411
+ _failed++;
412
+ }
413
+ }
303
414
  }
304
415
 
305
416
  // ── Progress Bar (thin, single-line) ────────────────────────────────────────
306
417
 
307
- /// Thin single-line progress bar with spinner, label, bar, percent, and time.
418
+ /// Braille single-line progress bar with spinner, label, bar, and time.
308
419
  ///
309
- /// Renders as: `⠹ Label ████░░░░ 50% 1.3s`
420
+ /// Renders as: `⠹ Label ⣿⣿⣿⣀⣀⣀ 50%`
310
421
  /// On finish: `✓ Label in 1.3s`
311
422
  class ProgressBar {
312
423
  ProgressBar(this.total, this.label);
@@ -335,11 +446,11 @@ class ProgressBar {
335
446
 
336
447
  final pct = total > 0 ? (_current / total).clamp(0.0, 1.0) : 1.0;
337
448
 
338
- // Thin bar: width scales to terminal, max 20 chars.
449
+ // Braille bar: width scales to terminal, max 20 chars.
339
450
  final barW = math.min(20, math.max(8, _cols - 40));
340
451
  final filled = (barW * pct).round();
341
452
  final empty = barW - filled;
342
- final bar = '${yellow('█' * filled)}${gray('░' * empty)}';
453
+ final bar = '${yellow('⣿' * filled)}${gray('⣀' * empty)}';
343
454
 
344
455
  _spin = (_spin + 1) % _frames.length;
345
456
  final pctStr = '${(pct * 100).round()}%'.padLeft(4);
@@ -349,10 +460,17 @@ class ProgressBar {
349
460
  );
350
461
  }
351
462
 
352
- void _finish() {
463
+ /// Finish: print the final status line. This is intentionally async and
464
+ /// fire-and-forget (called without `await` from [update] and [close]).
465
+ /// The 500ms delay keeps fast ops readable; if the process exits early,
466
+ /// the final line may not print — acceptable for a CLI progress aid.
467
+ void _finish() async {
353
468
  if (_closed) return;
354
469
  _closed = true;
355
470
  _sw.stop();
471
+ // Keep bar visible for at least 500ms so fast ops are readable.
472
+ final elapsed = _sw.elapsedMilliseconds;
473
+ if (elapsed < 500) await Future.delayed(Duration(milliseconds: 500 - elapsed));
356
474
  final ms = _sw.elapsedMilliseconds;
357
475
  final time = ms >= 1000
358
476
  ? '${(ms / 1000).toStringAsFixed(1)}s'
@@ -159,49 +159,49 @@ packages:
159
159
  path: "../../plugins/aws"
160
160
  relative: true
161
161
  source: path
162
- version: "0.1.3"
162
+ version: "0.1.5"
163
163
  flutter_ota_kit_cloudflare:
164
164
  dependency: "direct main"
165
165
  description:
166
166
  path: "../../plugins/cloudflare"
167
167
  relative: true
168
168
  source: path
169
- version: "0.1.3"
169
+ version: "0.1.5"
170
170
  flutter_ota_kit_core:
171
171
  dependency: "direct main"
172
172
  description:
173
173
  path: "../core"
174
174
  relative: true
175
175
  source: path
176
- version: "0.1.2"
176
+ version: "0.1.4"
177
177
  flutter_ota_kit_plugin_core:
178
178
  dependency: "direct main"
179
179
  description:
180
180
  path: "../../plugins/plugin-core"
181
181
  relative: true
182
182
  source: path
183
- version: "0.0.4"
183
+ version: "0.0.6"
184
184
  flutter_ota_kit_pocketbase:
185
185
  dependency: "direct main"
186
186
  description:
187
187
  path: "../../plugins/pocketbase"
188
188
  relative: true
189
189
  source: path
190
- version: "0.1.0"
190
+ version: "0.1.2"
191
191
  flutter_ota_kit_postgres:
192
192
  dependency: "direct main"
193
193
  description:
194
194
  path: "../../plugins/postgres"
195
195
  relative: true
196
196
  source: path
197
- version: "0.1.3"
197
+ version: "0.1.5"
198
198
  flutter_ota_kit_supabase:
199
199
  dependency: "direct main"
200
200
  description:
201
201
  path: "../../plugins/supabase"
202
202
  relative: true
203
203
  source: path
204
- version: "0.1.4"
204
+ version: "0.1.6"
205
205
  frontend_server_client:
206
206
  dependency: transitive
207
207
  description:
@@ -1,12 +1 @@
1
- ## 0.1.2
2
-
3
- - Add dartdoc on internal API surfaces (10/10 pana dartdoc coverage).
4
-
5
- ## 0.1.1
6
-
7
- - Fix `<id>` angle bracket in dartdoc (`bundle.dart:23`) being interpreted as HTML by Markdown renderers.
8
- - Repository moved to `https://github.com/HYPER12755/flutter-ota-kit`.
9
-
10
- ## 0.1.0
11
-
12
- - Initial release of `flutter_ota_kit_core` as part of the flutter_ota_kit OTA toolkit.
1
+ ## 0.1.4
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 xuelinger
3
+ Copyright (c) 2026 HYPER12755
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  name: flutter_ota_kit_core
2
2
  description: Core data model, rollout cohorts, and shared utilities for flutter_ota_kit — Dart translation of hot-updater's packages/core.
3
- version: 0.1.2
3
+ version: 0.1.4
4
4
  homepage: https://github.com/HYPER12755/flutter-ota-kit
5
5
  repository: https://github.com/HYPER12755/flutter-ota-kit
6
6
  issue_tracker: https://github.com/HYPER12755/flutter-ota-kit/issues