@_nazmiforreal/flutter-ota 0.1.2 → 0.1.3

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 (61) hide show
  1. package/dart-src/packages/cli-tools/.dart_tool/package_config.json +496 -0
  2. package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +743 -0
  3. package/dart-src/packages/cli-tools/lib/src/cli_base.dart +7 -6
  4. package/dart-src/packages/cli-tools/lib/src/commands/build.dart +46 -49
  5. package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +44 -23
  6. package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +27 -17
  7. package/dart-src/packages/cli-tools/lib/src/commands/console.dart +11 -6
  8. package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +39 -22
  9. package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +22 -13
  10. package/dart-src/packages/cli-tools/lib/src/commands/init.dart +6 -5
  11. package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +15 -9
  12. package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +14 -8
  13. package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +37 -12
  14. package/dart-src/packages/cli-tools/lib/src/operations.dart +45 -0
  15. package/dart-src/packages/cli-tools/lib/src/pack.dart +9 -0
  16. package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +234 -0
  17. package/dart-src/packages/cli-tools/pubspec.lock +638 -0
  18. package/dart-src/packages/cli-tools/test/backends_test.dart +207 -0
  19. package/dart-src/packages/cli-tools/test/build_pack_test.dart +101 -0
  20. package/dart-src/packages/cli-tools/test/cli_test.dart +442 -0
  21. package/dart-src/packages/cli-tools/test/mocks/mock_aws_s3_client.dart +83 -0
  22. package/dart-src/packages/cli-tools/test/mocks/mock_d1_client.dart +256 -0
  23. package/dart-src/packages/cli-tools/test/mocks/mock_postgres_client.dart +347 -0
  24. package/dart-src/packages/cli-tools/test/standalone_backend_test.dart +315 -0
  25. package/dart-src/packages/core/test/rollout_test.dart +128 -0
  26. package/dart-src/packages/core/test/semver_test.dart +52 -0
  27. package/dart-src/packages/core/test/semver_vectors.json +1 -0
  28. package/dart-src/packages/core/test/types_test.dart +129 -0
  29. package/dart-src/packages/core/test/uuid_test.dart +38 -0
  30. package/dart-src/plugins/aws/test/aws_database_test.dart +167 -0
  31. package/dart-src/plugins/aws/test/aws_lambda_edge_storage_test.dart +190 -0
  32. package/dart-src/plugins/aws/test/aws_storage_test.dart +73 -0
  33. package/dart-src/plugins/aws/test/mock_aws_s3_client.dart +83 -0
  34. package/dart-src/plugins/cloudflare/test/cloudflare_worker_database_test.dart +122 -0
  35. package/dart-src/plugins/cloudflare/test/d1_bundle_mapper_test.dart +106 -0
  36. package/dart-src/plugins/cloudflare/test/d1_database_test.dart +190 -0
  37. package/dart-src/plugins/cloudflare/test/mock_d1_client.dart +256 -0
  38. package/dart-src/plugins/cloudflare/test/mock_r2_client.dart +83 -0
  39. package/dart-src/plugins/cloudflare/test/r2_storage_test.dart +77 -0
  40. package/dart-src/plugins/plugin-core/test/asset_storage_layout_test.dart +166 -0
  41. package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_store_test.dart +74 -0
  42. package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_test.dart +332 -0
  43. package/dart-src/plugins/plugin-core/test/create_blob_database_plugin_test.dart +711 -0
  44. package/dart-src/plugins/plugin-core/test/create_database_plugin_test.dart +294 -0
  45. package/dart-src/plugins/plugin-core/test/create_storage_plugin_test.dart +192 -0
  46. package/dart-src/plugins/plugin-core/test/generate_min_bundle_id_test.dart +51 -0
  47. package/dart-src/plugins/plugin-core/test/get_update_info_test.dart +1600 -0
  48. package/dart-src/plugins/plugin-core/test/helpers_test.dart +259 -0
  49. package/dart-src/plugins/plugin-core/test/query_bundles_test.dart +139 -0
  50. package/dart-src/plugins/plugin-core/test/request_update_bundle_state_test.dart +86 -0
  51. package/dart-src/plugins/plugin-core/test/uuidv7_test.dart +77 -0
  52. package/dart-src/plugins/postgres/test/mock_postgres_client.dart +297 -0
  53. package/dart-src/plugins/postgres/test/postgres_bundle_mapper_test.dart +140 -0
  54. package/dart-src/plugins/postgres/test/postgres_database_test.dart +181 -0
  55. package/dart-src/plugins/standalone/test/standalone_test.dart +264 -0
  56. package/dart-src/plugins/supabase/test/mock_supabase_client.dart +654 -0
  57. package/dart-src/plugins/supabase/test/supabase_bundle_mapper_test.dart +112 -0
  58. package/dart-src/plugins/supabase/test/supabase_database_test.dart +207 -0
  59. package/dart-src/plugins/supabase/test/supabase_storage_test.dart +175 -0
  60. package/package.json +1 -1
  61. package/scripts/vendor.sh +29 -0
@@ -2,10 +2,11 @@ import 'dart:io';
2
2
 
3
3
  import 'package:args/args.dart';
4
4
  import 'package:args/command_runner.dart';
5
+ import 'package:flutter_patcher_cli/flutter_patcher_cli.dart';
5
6
  import 'package:path/path.dart' as p;
6
7
  import 'package:postgres/postgres.dart';
7
8
 
8
- import '../cli_base.dart';
9
+ import '../ui/ui.dart';
9
10
 
10
11
  /// `flutter_patcher migrate` — run backend SQL migrations.
11
12
  class MigrateCommand extends Command<int> {
@@ -63,6 +64,7 @@ class MigrateCommand extends Command<int> {
63
64
  }
64
65
 
65
66
  if (argResults!['dry-run'] as bool) {
67
+ banner('migrate · dry-run');
66
68
  for (final f in files) {
67
69
  stdout.writeln('--- ${p.basename(f.path)} ---');
68
70
  stdout.writeln(f.readAsStringSync());
@@ -73,11 +75,14 @@ class MigrateCommand extends Command<int> {
73
75
  final url = argResults!['database-url'] as String? ??
74
76
  Platform.environment['DATABASE_URL'];
75
77
  if (provider == 'cloudflare' || provider == 'aws') {
76
- stdout.writeln(
77
- 'Migrations for "$provider" are applied via its platform tooling '
78
- '(e.g. `wrangler d1 execute` for cloudflare, or the AWS console for '
79
- 'S3/DynamoDB). The SQL above shows the intended schema.',
80
- );
78
+ banner('migrate');
79
+ box('migrate', [
80
+ 'Migrations for "$provider" are applied via its platform tooling:',
81
+ ' cloudflare `wrangler d1 execute`',
82
+ ' • aws → AWS console (S3/DynamoDB)',
83
+ '',
84
+ 'The SQL files under ${dir} show the intended schema.',
85
+ ]);
81
86
  return;
82
87
  }
83
88
  if (url == null || url.isEmpty) {
@@ -94,6 +99,7 @@ class MigrateCommand extends Command<int> {
94
99
  ? uri.userInfo.split(':').last
95
100
  : null,
96
101
  );
102
+ banner('migrate');
97
103
  final conn = await Connection.open(
98
104
  endpoint,
99
105
  settings: ConnectionSettings(sslMode: SslMode.disable),
@@ -112,7 +118,7 @@ class MigrateCommand extends Command<int> {
112
118
  queryMode: QueryMode.simple,
113
119
  );
114
120
  if (existing.isNotEmpty) {
115
- stdout.writeln('skip $name (already applied)');
121
+ stdout.writeln(' ${dim('skip')} $name (already applied)');
116
122
  continue;
117
123
  }
118
124
  final statements = _splitStatements(file.readAsStringSync());
@@ -123,7 +129,7 @@ class MigrateCommand extends Command<int> {
123
129
  "INSERT INTO _flutter_patcher_migrations(name) VALUES ('$escaped')",
124
130
  queryMode: QueryMode.simple,
125
131
  );
126
- stdout.writeln('applied $name');
132
+ step('applied $name');
127
133
  }
128
134
  } finally {
129
135
  await conn.close();
@@ -1,14 +1,11 @@
1
- import 'dart:io';
2
-
3
1
  import 'package:args/args.dart';
4
2
  import 'package:args/command_runner.dart';
3
+ import 'package:flutter_patcher_cli/flutter_patcher_cli.dart';
4
+ import 'package:flutter_patcher_core/flutter_patcher_core.dart';
5
5
 
6
- import '../backend.dart';
7
- import '../cli_base.dart';
8
- import '../config.dart';
9
- import '../operations.dart';
6
+ import '../ui/ui.dart';
10
7
 
11
- /// `flutter_patcher rollback` — disable the latest enabled bundle on a channel.
8
+ /// `flutter_patcher rollback` — roll a channel back to a previous bundle.
12
9
  class RollbackCommand extends Command<int> {
13
10
  RollbackCommand({this.config, this.backendOverride});
14
11
 
@@ -20,22 +17,50 @@ class RollbackCommand extends Command<int> {
20
17
 
21
18
  @override
22
19
  String get description =>
23
- 'Disable the latest enabled bundle on a channel (roll back).';
20
+ 'Roll a channel back: disable the latest enabled bundle (or a specific --bundle-id).';
24
21
 
25
22
  @override
26
23
  ArgParser get argParser => ArgParser()
27
24
  ..addOption('backend', abbr: 'b', help: 'Backend provider.')
28
- ..addOption('channel', abbr: 'c', help: 'Channel to roll back.');
25
+ ..addOption('channel', abbr: 'c', help: 'Channel to roll back.')
26
+ ..addOption('bundle-id', help: 'Roll back to this specific bundle id.')
27
+ ..addOption('platform', abbr: 'p', help: 'Platform filter (e.g. android).');
29
28
 
30
29
  @override
31
30
  Future<int> run() => runGuarded(() async {
32
31
  final channel = argResults!['channel'] as String?;
33
32
  if (channel == null || channel.isEmpty) {
34
- throw StateError('Usage: flutter_patcher rollback --channel <channel>');
33
+ throw PackException(
34
+ 'Usage: flutter_patcher rollback --channel <channel> [--bundle-id <id>]',
35
+ 64,
36
+ );
35
37
  }
38
+ final bundleId = argResults!['bundle-id'] as String?;
39
+ final platformRaw = argResults!['platform'] as String?;
40
+ final platform =
41
+ platformRaw == null ? null : Platform.fromValue(platformRaw);
42
+
36
43
  final cfg = effectiveConfig(config ?? loadConfig(), argResults!);
37
44
  final backend = requireBackend(cfg, override: backendOverride);
38
- final id = await rollbackChannel(backend, channel);
39
- stdout.writeln('Rolled back channel "$channel" (disabled bundle $id)');
45
+
46
+ banner('rollback');
47
+ final (disabled, live) = await spinner(
48
+ () async {
49
+ if (bundleId != null && bundleId.isNotEmpty) {
50
+ return rollbackToBundle(backend, channel, bundleId,
51
+ platform: platform);
52
+ }
53
+ final id = await rollbackChannel(backend, channel);
54
+ final nowLive = (await getChannel(backend, channel))?.id ?? '';
55
+ return (<String>[id], nowLive);
56
+ },
57
+ 'Rolling back channel "$channel"',
58
+ done: 'Rolled back',
59
+ );
60
+ box('rollback', [
61
+ kv('channel', channel),
62
+ kv('now live', cyan(live)),
63
+ kv('disabled', disabled.isEmpty ? dim('(none)') : disabled.join(', ')),
64
+ ]);
40
65
  });
41
66
  }
@@ -193,6 +193,51 @@ Future<String> rollbackChannel(Backend backend, String channel) async {
193
193
  return target.id;
194
194
  }
195
195
 
196
+ /// Roll back a channel to a specific [targetBundleId]: disable every enabled
197
+ /// bundle newer than it (by creation order) and ensure the target is enabled.
198
+ ///
199
+ /// Returns `(disabledBundleIds, liveBundleId)`.
200
+ Future<(List<String>, String)> rollbackToBundle(
201
+ Backend backend,
202
+ String channel,
203
+ String targetBundleId, {
204
+ Platform? platform,
205
+ }) async {
206
+ final res = await backend.db.getBundles(
207
+ DatabaseBundleQueryOptions(
208
+ where: DatabaseBundleQueryWhere(channel: channel, platform: platform),
209
+ orderBy: const DatabaseBundleQueryOrder(direction: 'desc'),
210
+ limit: 100,
211
+ ),
212
+ );
213
+ final bundles = res.data;
214
+ Bundle? target;
215
+ for (final b in bundles) {
216
+ if (b.id == targetBundleId) {
217
+ target = b;
218
+ break;
219
+ }
220
+ }
221
+ if (target == null) {
222
+ throw StateError(
223
+ 'Bundle "$targetBundleId" not found on channel "$channel".',
224
+ );
225
+ }
226
+ final targetIndex = bundles.indexOf(target);
227
+ final disabled = <String>[];
228
+ for (var i = 0; i < targetIndex; i++) {
229
+ if (bundles[i].enabled) {
230
+ disabled.add(bundles[i].id);
231
+ await backend.db.updateBundle(bundles[i].id, {'enabled': false});
232
+ }
233
+ }
234
+ if (!target.enabled) {
235
+ await backend.db.updateBundle(target.id, {'enabled': true});
236
+ }
237
+ await backend.db.commitBundle();
238
+ return (disabled, target.id);
239
+ }
240
+
196
241
  /// List channels configured on the backend.
197
242
  Future<List<String>> listChannels(Backend backend) => backend.db.getChannels();
198
243
 
@@ -42,6 +42,7 @@ Future<PackResult> packPatch({
42
42
  String? abi,
43
43
  List<String> requestedAssets = const [],
44
44
  required String out,
45
+ void Function(int done, int total, String label)? onProgress,
45
46
  }) async {
46
47
  final apkFile = File(apkPath);
47
48
  if (!apkFile.existsSync()) {
@@ -72,6 +73,7 @@ Future<PackResult> packPatch({
72
73
  version: version,
73
74
  targetVersionCode: targetVersionCode,
74
75
  requestedAssets: requestedAssets,
76
+ onProgress: onProgress,
75
77
  );
76
78
  return result;
77
79
  }
@@ -83,6 +85,7 @@ PackResult _writePatchPackage({
83
85
  required String version,
84
86
  required int targetVersionCode,
85
87
  required List<String> requestedAssets,
88
+ void Function(int done, int total, String label)? onProgress,
86
89
  }) {
87
90
  final patchFiles = <String, _PatchAssetFile>{};
88
91
  final operations = <Map<String, dynamic>>[];
@@ -169,9 +172,14 @@ PackResult _writePatchPackage({
169
172
 
170
173
  final package = Archive()
171
174
  ..addFile(_jsonArchiveFile('manifest.json', zipManifest));
175
+ final progressTotal = libs.length + 1;
176
+ onProgress?.call(0, progressTotal, 'libapp.so');
177
+ var progressDone = 0;
172
178
  for (final (currentAbi, soBytes) in libs) {
173
179
  package.addFile(
174
180
  ArchiveFile('lib/$currentAbi/libapp.so', soBytes.length, soBytes));
181
+ progressDone++;
182
+ onProgress?.call(progressDone, progressTotal, 'lib/$currentAbi/libapp.so');
175
183
  }
176
184
 
177
185
  if (requestedAssets.isNotEmpty) {
@@ -194,6 +202,7 @@ PackResult _writePatchPackage({
194
202
  final List<int> packageBytes = ZipEncoder().encode(package) ?? const <int>[];
195
203
  final outZip = File('${outDir.path}/patch.zip');
196
204
  outZip.writeAsBytesSync(packageBytes);
205
+ onProgress?.call(progressTotal, progressTotal, 'patch.zip');
197
206
 
198
207
  final payloadMd5 = md5.convert(packageBytes).toString();
199
208
  final outerManifest = <String, dynamic>{
@@ -0,0 +1,234 @@
1
+ library;
2
+
3
+ import 'dart:async';
4
+ import 'dart:io';
5
+ import 'dart:math' as math;
6
+
7
+ /// Terminal UI toolkit for the flutter_patcher CLI: colored text, banners,
8
+ /// bordered boxes, step logs, dynamic spinners and progress bars.
9
+ ///
10
+ /// Everything degrades gracefully to plain text when stdout is not a TTY or the
11
+ /// `NO_COLOR` env var is set, so pipes/CI/tests stay clean. Boxes auto-size to
12
+ /// the terminal width and wrap long content so they never render broken.
13
+
14
+ const _kReset = '\x1b[0m';
15
+ const _kRed = '\x1b[31m';
16
+ const _kGreen = '\x1b[32m';
17
+ const _kYellow = '\x1b[33m';
18
+ const _kBlue = '\x1b[34m';
19
+ const _kMagenta = '\x1b[35m';
20
+ const _kCyan = '\x1b[36m';
21
+ const _kGray = '\x1b[90m';
22
+ const _kBold = '\x1b[1m';
23
+
24
+ const List<String> _frames = [
25
+ '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏',
26
+ ];
27
+
28
+ bool get _noColor {
29
+ final v = Platform.environment['NO_COLOR'];
30
+ return v != null && v.isNotEmpty;
31
+ }
32
+
33
+ bool get _colorOn =>
34
+ !_noColor && stdout.hasTerminal && stdout.supportsAnsiEscapes;
35
+
36
+ String _c(String code, String s) => _colorOn ? '$code$s$_kReset' : s;
37
+
38
+ String red(String s) => _c(_kRed, s);
39
+ String green(String s) => _c(_kGreen, s);
40
+ String yellow(String s) => _c(_kYellow, s);
41
+ String blue(String s) => _c(_kBlue, s);
42
+ String magenta(String s) => _c(_kMagenta, s);
43
+ String cyan(String s) => _c(_kCyan, s);
44
+ String gray(String s) => _c(_kGray, s);
45
+ String dim(String s) => _c(_kGray, s);
46
+ String bold(String s) => _c(_kBold, s);
47
+
48
+ int get _cols => stdout.hasTerminal ? stdout.terminalColumns : 80;
49
+
50
+ final _ansi = RegExp('\x1b\\[[0-9;]*m');
51
+
52
+ int _dispWidth(String s) => s.replaceAll(_ansi, '').length;
53
+
54
+ void _echo(String s) => stdout.writeln(s);
55
+
56
+ /// `key value` laid out in a fixed 18-char column (visible-width aware).
57
+ String kv(String k, String v) {
58
+ final pad = math.max(0, 18 - _dispWidth(k));
59
+ return '${dim(k)}${' ' * pad} $v';
60
+ }
61
+
62
+ void banner(String name) {
63
+ _echo('');
64
+ _echo(' ${cyan('▶')} ${cyan(bold('flutter_patcher'))} ${dim('·')} ${bold(name)}');
65
+ _echo(' ${dim('─' * math.min(_cols - 4, 60))}');
66
+ }
67
+
68
+ void step(String msg) => _echo(' ${green('✓')} $msg');
69
+
70
+ void err(String msg) =>
71
+ _echo(' ${red('✗')} ${red(msg)}');
72
+
73
+ /// Run [task] while showing a live spinner; replaces the line with a
74
+ /// `${green('✓')} done` summary when finished (or `✗` on error).
75
+ Future<T> spinner<T>(
76
+ Future<T> Function() task,
77
+ String label, {
78
+ String? done,
79
+ }) async {
80
+ if (!_colorOn) {
81
+ _echo(' ${dim('•')} $label');
82
+ final r = await task();
83
+ _echo(' ${green('✓')} ${done ?? label}');
84
+ return r;
85
+ }
86
+ var i = 0;
87
+ final timer = Timer.periodic(const Duration(milliseconds: 80), (_) {
88
+ i = (i + 1) % _frames.length;
89
+ stdout.write('\r\x1b[K ${cyan(_frames[i])} $label');
90
+ });
91
+ try {
92
+ final r = await task();
93
+ timer.cancel();
94
+ stdout.write('\r\x1b[K ${green('✓')} ${done ?? label}\n');
95
+ return r;
96
+ } catch (e) {
97
+ timer.cancel();
98
+ stdout.write('\r\x1b[K ${red('✗')} $label\n');
99
+ rethrow;
100
+ }
101
+ }
102
+
103
+ List<String> _wrap(String text, int inner) {
104
+ if (text.isEmpty) return [''];
105
+ final result = <String>[];
106
+ for (final raw in text.split('\n')) {
107
+ if (_dispWidth(raw) <= inner) {
108
+ result.add(raw);
109
+ continue;
110
+ }
111
+ var line = '';
112
+ for (final word in raw.split(' ')) {
113
+ if (word.isEmpty) continue;
114
+ if (line.isEmpty) {
115
+ if (_dispWidth(word) <= inner) {
116
+ line = word;
117
+ } else {
118
+ var rem = word;
119
+ while (_dispWidth(rem) > inner) {
120
+ final cut = _cut(rem, inner);
121
+ result.add(cut);
122
+ rem = rem.substring(cut.length);
123
+ }
124
+ line = rem;
125
+ }
126
+ } else if (_dispWidth(line) + 1 + _dispWidth(word) <= inner) {
127
+ line += ' $word';
128
+ } else {
129
+ result.add(line);
130
+ line = word.length <= inner
131
+ ? word
132
+ : _splitLong(word, inner, result);
133
+ }
134
+ }
135
+ result.add(line);
136
+ }
137
+ return result;
138
+ }
139
+
140
+ String _cut(String s, int inner) {
141
+ final buf = StringBuffer();
142
+ for (final ch in s.runes) {
143
+ if (buf.length + 1 > inner) break;
144
+ buf.writeCharCode(ch);
145
+ }
146
+ return buf.toString();
147
+ }
148
+
149
+ String _splitLong(String word, int inner, List<String> out) {
150
+ var rem = word;
151
+ while (_dispWidth(rem) > inner) {
152
+ final cut = _cut(rem, inner);
153
+ out.add(cut);
154
+ rem = rem.substring(cut.length);
155
+ }
156
+ return rem;
157
+ }
158
+
159
+ int _boxWidth(List<String> lines, String title) {
160
+ var longest = _dispWidth(title) + 4;
161
+ for (final l in lines) {
162
+ for (final w in l.split('\n')) {
163
+ longest = math.max(longest, _dispWidth(w));
164
+ }
165
+ }
166
+ final target = math.max(longest + 4, 40);
167
+ return math.max(math.min(_cols - 1, target), 20);
168
+ }
169
+
170
+ /// Bordered box with a centered title; auto-sizes to the terminal and wraps
171
+ /// long content so the right/left edges always stay aligned.
172
+ void box(String title, List<String> lines) {
173
+ final width = _boxWidth(lines, title);
174
+ final inner = width - 4;
175
+ final wrapped = <String>[];
176
+ for (final l in lines) wrapped.addAll(_wrap(l, inner));
177
+
178
+ final titleText = ' $title ';
179
+ final tVisible = _dispWidth(titleText);
180
+ var dashes = math.max(2, width - 2 - tVisible);
181
+ final left = math.max(1, dashes ~/ 2);
182
+ final right = dashes - left;
183
+ final top =
184
+ '╭${'─' * left}$titleText${'─' * right}╮';
185
+ _echo(top);
186
+ for (final l in wrapped) {
187
+ final pad = math.max(0, inner - _dispWidth(l));
188
+ _echo('│ $l${' ' * pad} │');
189
+ }
190
+ _echo('╰${'─' * (width - 2)}╯');
191
+ }
192
+
193
+ /// Live progress bar with an animated spinner prefix. Call [update] as work
194
+ /// progresses; it renders a `[████░░] 45%` bar that overwrites the same line.
195
+ class ProgressBar {
196
+ ProgressBar(this.total, this.label);
197
+
198
+ int total;
199
+ final String label;
200
+ int _current = 0;
201
+ int _spin = 0;
202
+ bool _closed = false;
203
+
204
+ void update(int current, [String? label]) {
205
+ _current = current;
206
+ if (label != null) _label = label;
207
+ _draw();
208
+ if (total > 0 && _current >= total) _finish();
209
+ }
210
+
211
+ late String _label = label;
212
+
213
+ void _draw() {
214
+ if (_closed) return;
215
+ if (!_colorOn) {
216
+ _echo(' $_label: $_current/$total');
217
+ return;
218
+ }
219
+ const barW = 28;
220
+ final pct = total > 0 ? (_current / total).clamp(0.0, 1.0) : 1.0;
221
+ final filled = (barW * pct).round();
222
+ final bar =
223
+ '${green('█' * filled)}${gray('·' * (barW - filled))}';
224
+ _spin = (_spin + 1) % _frames.length;
225
+ final pctStr = '${(pct * 100).round()}%'.padLeft(4);
226
+ stdout.write('\r\x1b[K ${cyan(_frames[_spin])} $_label [$bar] $pctStr');
227
+ }
228
+
229
+ void _finish() {
230
+ if (_closed) return;
231
+ _closed = true;
232
+ if (_colorOn) stdout.write('\n');
233
+ }
234
+ }