@_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.
- package/dart-src/packages/cli-tools/.dart_tool/package_config.json +496 -0
- package/dart-src/packages/cli-tools/.dart_tool/package_graph.json +743 -0
- package/dart-src/packages/cli-tools/lib/src/cli_base.dart +7 -6
- package/dart-src/packages/cli-tools/lib/src/commands/build.dart +46 -49
- package/dart-src/packages/cli-tools/lib/src/commands/bundle.dart +44 -23
- package/dart-src/packages/cli-tools/lib/src/commands/channel.dart +27 -17
- package/dart-src/packages/cli-tools/lib/src/commands/console.dart +11 -6
- package/dart-src/packages/cli-tools/lib/src/commands/deploy.dart +39 -22
- package/dart-src/packages/cli-tools/lib/src/commands/doctor.dart +22 -13
- package/dart-src/packages/cli-tools/lib/src/commands/init.dart +6 -5
- package/dart-src/packages/cli-tools/lib/src/commands/keys.dart +15 -9
- package/dart-src/packages/cli-tools/lib/src/commands/migrate.dart +14 -8
- package/dart-src/packages/cli-tools/lib/src/commands/rollback.dart +37 -12
- package/dart-src/packages/cli-tools/lib/src/operations.dart +45 -0
- package/dart-src/packages/cli-tools/lib/src/pack.dart +9 -0
- package/dart-src/packages/cli-tools/lib/src/ui/ui.dart +234 -0
- package/dart-src/packages/cli-tools/pubspec.lock +638 -0
- package/dart-src/packages/cli-tools/test/backends_test.dart +207 -0
- package/dart-src/packages/cli-tools/test/build_pack_test.dart +101 -0
- package/dart-src/packages/cli-tools/test/cli_test.dart +442 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_aws_s3_client.dart +83 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_d1_client.dart +256 -0
- package/dart-src/packages/cli-tools/test/mocks/mock_postgres_client.dart +347 -0
- package/dart-src/packages/cli-tools/test/standalone_backend_test.dart +315 -0
- package/dart-src/packages/core/test/rollout_test.dart +128 -0
- package/dart-src/packages/core/test/semver_test.dart +52 -0
- package/dart-src/packages/core/test/semver_vectors.json +1 -0
- package/dart-src/packages/core/test/types_test.dart +129 -0
- package/dart-src/packages/core/test/uuid_test.dart +38 -0
- package/dart-src/plugins/aws/test/aws_database_test.dart +167 -0
- package/dart-src/plugins/aws/test/aws_lambda_edge_storage_test.dart +190 -0
- package/dart-src/plugins/aws/test/aws_storage_test.dart +73 -0
- package/dart-src/plugins/aws/test/mock_aws_s3_client.dart +83 -0
- package/dart-src/plugins/cloudflare/test/cloudflare_worker_database_test.dart +122 -0
- package/dart-src/plugins/cloudflare/test/d1_bundle_mapper_test.dart +106 -0
- package/dart-src/plugins/cloudflare/test/d1_database_test.dart +190 -0
- package/dart-src/plugins/cloudflare/test/mock_d1_client.dart +256 -0
- package/dart-src/plugins/cloudflare/test/mock_r2_client.dart +83 -0
- package/dart-src/plugins/cloudflare/test/r2_storage_test.dart +77 -0
- package/dart-src/plugins/plugin-core/test/asset_storage_layout_test.dart +166 -0
- package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_store_test.dart +74 -0
- package/dart-src/plugins/plugin-core/test/bundle_unit_of_work_test.dart +332 -0
- package/dart-src/plugins/plugin-core/test/create_blob_database_plugin_test.dart +711 -0
- package/dart-src/plugins/plugin-core/test/create_database_plugin_test.dart +294 -0
- package/dart-src/plugins/plugin-core/test/create_storage_plugin_test.dart +192 -0
- package/dart-src/plugins/plugin-core/test/generate_min_bundle_id_test.dart +51 -0
- package/dart-src/plugins/plugin-core/test/get_update_info_test.dart +1600 -0
- package/dart-src/plugins/plugin-core/test/helpers_test.dart +259 -0
- package/dart-src/plugins/plugin-core/test/query_bundles_test.dart +139 -0
- package/dart-src/plugins/plugin-core/test/request_update_bundle_state_test.dart +86 -0
- package/dart-src/plugins/plugin-core/test/uuidv7_test.dart +77 -0
- package/dart-src/plugins/postgres/test/mock_postgres_client.dart +297 -0
- package/dart-src/plugins/postgres/test/postgres_bundle_mapper_test.dart +140 -0
- package/dart-src/plugins/postgres/test/postgres_database_test.dart +181 -0
- package/dart-src/plugins/standalone/test/standalone_test.dart +264 -0
- package/dart-src/plugins/supabase/test/mock_supabase_client.dart +654 -0
- package/dart-src/plugins/supabase/test/supabase_bundle_mapper_test.dart +112 -0
- package/dart-src/plugins/supabase/test/supabase_database_test.dart +207 -0
- package/dart-src/plugins/supabase/test/supabase_storage_test.dart +175 -0
- package/package.json +1 -1
- 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 '../
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
'
|
|
79
|
-
'
|
|
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
|
-
|
|
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 '../
|
|
7
|
-
import '../cli_base.dart';
|
|
8
|
-
import '../config.dart';
|
|
9
|
-
import '../operations.dart';
|
|
6
|
+
import '../ui/ui.dart';
|
|
10
7
|
|
|
11
|
-
/// `flutter_patcher rollback` —
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
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
|
+
}
|