@_nazmiforreal/flutter-ota 0.1.27 → 0.1.28

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.
@@ -30,6 +30,10 @@ class OtaOverlayState {
30
30
  final List<LogLine> logs;
31
31
  final bool canRetry;
32
32
 
33
+ /// True once the patch is installed and the app is about to cold-restart.
34
+ /// Drives the final "restarting" phase label + the completed steps list.
35
+ final bool restarting;
36
+
33
37
  /// Which step index is currently active (0-based). Steps that are done
34
38
  /// get a check; the active step gets the braille spinner.
35
39
  final int activeStep;
@@ -53,6 +57,7 @@ class OtaOverlayState {
53
57
  this.totalBytes = 0,
54
58
  this.logs = const [],
55
59
  this.canRetry = false,
60
+ this.restarting = false,
56
61
  this.activeStep = 0,
57
62
  });
58
63
 
@@ -74,6 +79,7 @@ class OtaOverlayState {
74
79
  int? totalBytes,
75
80
  List<LogLine>? logs,
76
81
  bool? canRetry,
82
+ bool? restarting,
77
83
  int? activeStep,
78
84
  }) {
79
85
  return OtaOverlayState(
@@ -95,6 +101,7 @@ class OtaOverlayState {
95
101
  totalBytes: totalBytes ?? this.totalBytes,
96
102
  logs: logs ?? this.logs,
97
103
  canRetry: canRetry ?? this.canRetry,
104
+ restarting: restarting ?? this.restarting,
98
105
  activeStep: activeStep ?? this.activeStep,
99
106
  );
100
107
  }
@@ -341,14 +348,21 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
341
348
  // ── STEPS ─────────────────────────────────────────────────────────────
342
349
  Widget _stepsBlock(OtaOverlayState s) {
343
350
  final spinner = _braille[_spinnerIndex];
351
+ // When restarting, every step is complete. Otherwise the active index is
352
+ // clamped into range so an out-of-bounds activeStep can never leave the
353
+ // list looking stuck or skip the visible progression.
354
+ final int activeIndex = s.restarting
355
+ ? _stepLabels.length
356
+ : s.activeStep.clamp(0, _stepLabels.length - 1);
357
+
344
358
  return _panelBox(
345
359
  child: Column(
346
360
  crossAxisAlignment: CrossAxisAlignment.start,
347
361
  children: List.generate(_stepLabels.length, (i) {
348
- final done = i < s.activeStep;
349
- final active = i == s.activeStep && !s.hasError;
350
- final failed = i == s.activeStep && s.hasError;
351
- final pending = i > s.activeStep;
362
+ final done = i < activeIndex;
363
+ final active = i == activeIndex && !s.hasError && !s.restarting;
364
+ final failed = i == activeIndex && s.hasError;
365
+ final pending = i > activeIndex;
352
366
 
353
367
  final Color color = failed
354
368
  ? _C.red
@@ -366,13 +380,21 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
366
380
  ? spinner
367
381
  : '·';
368
382
 
369
- final String status = failed
370
- ? 'error'
371
- : done
372
- ? 'done'
373
- : active
374
- ? _pct(s.fraction)
375
- : 'wait';
383
+ // Only the downloading step shows a percentage. Verify / install /
384
+ // finalize have no meaningful fraction, so they show a live "working"
385
+ // spinner instead of a frozen number — nothing looks hung.
386
+ final String status;
387
+ if (failed) {
388
+ status = 'error';
389
+ } else if (done) {
390
+ status = 'done';
391
+ } else if (active) {
392
+ final showPct = s.phase == PatchApplyPhase.downloading &&
393
+ s.fraction != null;
394
+ status = showPct ? _pct(s.fraction) : 'working $spinner';
395
+ } else {
396
+ status = 'wait';
397
+ }
376
398
 
377
399
  return Padding(
378
400
  padding: const EdgeInsets.symmetric(vertical: 3),
@@ -415,11 +437,6 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
415
437
 
416
438
  // ── PROGRESS ──────────────────────────────────────────────────────────
417
439
  Widget _progressBlock(OtaOverlayState s) {
418
- const total = 34;
419
- final frac = (s.fraction ?? 0).clamp(0.0, 1.0);
420
- final filled = (frac * total).round();
421
- final bar = '${'█' * filled}${'░' * (total - filled)}';
422
-
423
440
  final speed = (s.bytesPerSec != null && s.bytesPerSec! > 0)
424
441
  ? '${(s.bytesPerSec! / 1048576).toStringAsFixed(1)} MB/s'
425
442
  : '—';
@@ -428,31 +445,54 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
428
445
  s.bytesPerSec! > 0 &&
429
446
  s.totalBytes > s.bytesReceived) {
430
447
  final secs = ((s.totalBytes - s.bytesReceived) / s.bytesPerSec!).ceil();
431
- eta = '${secs}s';
448
+ eta = secs >= 60 ? '${(secs / 60).ceil()}m' : '${secs}s';
432
449
  }
433
450
  final phase = s.hasError
434
451
  ? 'halted'
435
- : (s.phase?.name ?? 'preparing');
452
+ : (s.restarting ? 'restarting' : (s.phase?.name ?? 'preparing'));
453
+
454
+ // A determinate fraction is only meaningful while downloading with a known
455
+ // Content-Length. Verifying / finalizing / unknown-size downloads render an
456
+ // indeterminate (sliding) bar so nothing ever looks stuck at 0%.
457
+ final bool determinate = !s.hasError &&
458
+ s.phase == PatchApplyPhase.downloading &&
459
+ s.fraction != null;
460
+ final double? frac = s.hasError
461
+ ? (s.fraction ?? 0)
462
+ : (determinate ? s.fraction : null);
436
463
 
437
464
  return _panelBox(
438
465
  child: Column(
439
466
  crossAxisAlignment: CrossAxisAlignment.start,
440
467
  children: [
441
- RichText(
442
- text: TextSpan(
443
- style: const TextStyle(fontFamily: _mono, fontSize: 13),
444
- children: [
445
- TextSpan(
446
- text: bar,
447
- style: TextStyle(color: s.hasError ? _C.red : _C.blue)),
448
- TextSpan(
449
- text: ' ${_pct(s.fraction)}',
450
- style: const TextStyle(
451
- color: _C.text, fontWeight: FontWeight.w700)),
452
- ],
453
- ),
468
+ Row(
469
+ children: [
470
+ // The bar is a real widget (never wraps), and animates smoothly
471
+ // between values instead of snapping.
472
+ Expanded(
473
+ child: _ProgressBar(
474
+ fraction: frac,
475
+ color: s.hasError ? _C.red : _C.blue,
476
+ ),
477
+ ),
478
+ const SizedBox(width: 10),
479
+ SizedBox(
480
+ width: 44,
481
+ child: Text(
482
+ determinate ? _pct(s.fraction) : '',
483
+ textAlign: TextAlign.right,
484
+ style: const TextStyle(
485
+ fontFamily: _mono,
486
+ fontSize: 13,
487
+ color: _C.text,
488
+ fontWeight: FontWeight.w700,
489
+ fontFeatures: [FontFeature.tabularFigures()],
490
+ ),
491
+ ),
492
+ ),
493
+ ],
454
494
  ),
455
- const SizedBox(height: 10),
495
+ const SizedBox(height: 12),
456
496
  Row(
457
497
  children: [
458
498
  _stat('speed', speed),
@@ -604,6 +644,133 @@ class _OtaProgressOverlayState extends State<OtaProgressOverlay> {
604
644
  );
605
645
  }
606
646
 
647
+ /// A single-line progress bar that never wraps.
648
+ ///
649
+ /// - When [fraction] is non-null it's a determinate bar that animates smoothly
650
+ /// between values (no snapping / jumping).
651
+ /// - When [fraction] is null it's an indeterminate sliding bar, so
652
+ /// verify/install/finalize (which have no meaningful percentage) never look
653
+ /// frozen at 0%.
654
+ ///
655
+ /// It's a real painted widget with a fixed height inside an [Expanded], so it
656
+ /// always stays on one line regardless of width — unlike the old
657
+ /// character-string bar, which wrapped when it didn't fit.
658
+ class _ProgressBar extends StatefulWidget {
659
+ final double? fraction;
660
+ final Color color;
661
+
662
+ const _ProgressBar({required this.fraction, required this.color});
663
+
664
+ @override
665
+ State<_ProgressBar> createState() => _ProgressBarState();
666
+ }
667
+
668
+ class _ProgressBarState extends State<_ProgressBar>
669
+ with SingleTickerProviderStateMixin {
670
+ late final AnimationController _indeterminate = AnimationController(
671
+ vsync: this,
672
+ duration: const Duration(milliseconds: 1200),
673
+ );
674
+
675
+ // Smoothed determinate value so the bar eases toward the target instead of
676
+ // snapping. Monotonic — it never visually goes backwards.
677
+ double _shown = 0;
678
+
679
+ @override
680
+ void initState() {
681
+ super.initState();
682
+ _shown = widget.fraction ?? 0;
683
+ if (widget.fraction == null) _indeterminate.repeat();
684
+ }
685
+
686
+ @override
687
+ void didUpdateWidget(covariant _ProgressBar old) {
688
+ super.didUpdateWidget(old);
689
+ if (widget.fraction == null) {
690
+ if (!_indeterminate.isAnimating) _indeterminate.repeat();
691
+ } else {
692
+ if (_indeterminate.isAnimating) _indeterminate.stop();
693
+ final target = widget.fraction!.clamp(0.0, 1.0);
694
+ if (target >= _shown) _shown = target; // forward-only
695
+ }
696
+ }
697
+
698
+ @override
699
+ void dispose() {
700
+ _indeterminate.dispose();
701
+ super.dispose();
702
+ }
703
+
704
+ static const _height = 8.0;
705
+ static const _track = Color(0xFF161C24);
706
+
707
+ @override
708
+ Widget build(BuildContext context) {
709
+ return ClipRRect(
710
+ borderRadius: BorderRadius.circular(99),
711
+ child: SizedBox(
712
+ height: _height,
713
+ child: ColoredBox(
714
+ color: _track,
715
+ child: widget.fraction == null
716
+ ? _indeterminateBar()
717
+ : _determinateBar(),
718
+ ),
719
+ ),
720
+ );
721
+ }
722
+
723
+ Widget _determinateBar() {
724
+ return LayoutBuilder(
725
+ builder: (context, c) => TweenAnimationBuilder<double>(
726
+ // Ease toward the smoothed target; short duration keeps it responsive
727
+ // but never snappy.
728
+ tween: Tween(end: _shown),
729
+ duration: const Duration(milliseconds: 400),
730
+ curve: Curves.easeOut,
731
+ builder: (context, v, _) => Align(
732
+ alignment: Alignment.centerLeft,
733
+ child: Container(
734
+ width: (c.maxWidth * v.clamp(0.0, 1.0)),
735
+ decoration: BoxDecoration(
736
+ color: widget.color,
737
+ borderRadius: BorderRadius.circular(99),
738
+ ),
739
+ ),
740
+ ),
741
+ ),
742
+ );
743
+ }
744
+
745
+ Widget _indeterminateBar() {
746
+ return LayoutBuilder(
747
+ builder: (context, c) => AnimatedBuilder(
748
+ animation: _indeterminate,
749
+ builder: (context, _) {
750
+ const segment = 0.35; // 35% wide moving segment
751
+ final t = _indeterminate.value; // 0..1
752
+ final left = (t * (1 + segment)) - segment; // slides in from the left
753
+ return Align(
754
+ alignment: Alignment.centerLeft,
755
+ child: Padding(
756
+ padding: EdgeInsets.only(
757
+ left: (left.clamp(0.0, 1.0)) * c.maxWidth,
758
+ ),
759
+ child: Container(
760
+ width: segment * c.maxWidth,
761
+ decoration: BoxDecoration(
762
+ color: widget.color,
763
+ borderRadius: BorderRadius.circular(99),
764
+ ),
765
+ ),
766
+ ),
767
+ );
768
+ },
769
+ ),
770
+ );
771
+ }
772
+ }
773
+
607
774
  /// Scrollable terminal log view — auto-scrolls to bottom on new lines.
608
775
  ///
609
776
  /// A line whose text has a leading token followed by 2+ spaces is rendered
@@ -706,6 +873,10 @@ class OtaOverlayManager {
706
873
  int _lastBytes = 0;
707
874
  DateTime? _lastBytesAt;
708
875
 
876
+ /// Last 10%-bucket we emitted a "download NN%" log line for, to throttle the
877
+ /// feed. -1 = none yet.
878
+ int _lastLoggedPctBucket = -1;
879
+
709
880
  OverlayState? Function()? _resolver;
710
881
 
711
882
  /// Minimum time the overlay stays visible (7 seconds).
@@ -742,6 +913,7 @@ class OtaOverlayManager {
742
913
  _startTime = DateTime.now();
743
914
  _lastBytes = 0;
744
915
  _lastBytesAt = null;
916
+ _lastLoggedPctBucket = -1;
745
917
 
746
918
  final logs = <LogLine>[
747
919
  if (channel != null) LogLine('gray', 'channel $channel'),
@@ -775,9 +947,13 @@ class OtaOverlayManager {
775
947
  void _log(String color, String text) {
776
948
  if (_disposed) return;
777
949
  final current = _state.value;
778
- _state.value = current.copyWith(
779
- logs: [...current.logs, LogLine(color, text)],
780
- );
950
+ final next = [...current.logs, LogLine(color, text)];
951
+ // Cap the feed so a long session can't grow it without bound.
952
+ const maxLines = 200;
953
+ final capped = next.length > maxLines
954
+ ? next.sublist(next.length - maxLines)
955
+ : next;
956
+ _state.value = current.copyWith(logs: capped);
781
957
  }
782
958
 
783
959
  /// Map phase to step index.
@@ -802,24 +978,37 @@ class OtaOverlayManager {
802
978
  ? '${(pct.clamp(0.0, 1.0) * 100).toStringAsFixed(0)}%'
803
979
  : '';
804
980
 
805
- final newStep = _phaseToStep(progress.phase);
981
+ // Step index derived from phase, but forced to move only forward so an
982
+ // out-of-order or duplicate native event can never make the steps jump
983
+ // backwards or flicker.
984
+ final phaseStep = _phaseToStep(progress.phase);
985
+ final newStep = phaseStep < s.activeStep ? s.activeStep : phaseStep;
806
986
 
807
- // Compute download speed from byte deltas.
987
+ // Compute a smoothed download speed from byte deltas. A single sample can
988
+ // spike wildly (buffered reads), so we blend with the previous value.
808
989
  int? bytesPerSec = s.bytesPerSec;
809
990
  if (progress.phase == PatchApplyPhase.downloading &&
810
991
  progress.bytesReceived > 0) {
811
992
  final now = DateTime.now();
812
993
  if (_lastBytesAt != null && progress.bytesReceived > _lastBytes) {
813
994
  final dt = now.difference(_lastBytesAt!).inMilliseconds / 1000.0;
814
- if (dt > 0) {
815
- bytesPerSec = ((progress.bytesReceived - _lastBytes) / dt).round();
995
+ if (dt > 0.05) {
996
+ final sample =
997
+ ((progress.bytesReceived - _lastBytes) / dt).round();
998
+ // Exponential moving average → the speed/ETA read-out stops jumping.
999
+ bytesPerSec = bytesPerSec == null || bytesPerSec == 0
1000
+ ? sample
1001
+ : (bytesPerSec * 0.6 + sample * 0.4).round();
1002
+ _lastBytes = progress.bytesReceived;
1003
+ _lastBytesAt = now;
816
1004
  }
1005
+ } else {
1006
+ _lastBytes = progress.bytesReceived;
1007
+ _lastBytesAt = now;
817
1008
  }
818
- _lastBytes = progress.bytesReceived;
819
- _lastBytesAt = now;
820
1009
  }
821
1010
 
822
- // Log phase transitions with staggered delays.
1011
+ // Log phase transitions once, with staggered detail lines.
823
1012
  if (s.phase != progress.phase) {
824
1013
  switch (progress.phase) {
825
1014
  case PatchApplyPhase.downloading:
@@ -841,12 +1030,17 @@ class OtaOverlayManager {
841
1030
  }
842
1031
  }
843
1032
 
844
- // Log download progress with size.
1033
+ // Throttle the "download NN%" log so a fast download doesn't spam dozens of
1034
+ // lines (which made the feed scroll frantically). One line per ~10%.
845
1035
  if (progress.phase == PatchApplyPhase.downloading &&
846
1036
  pctText.isNotEmpty &&
847
1037
  progress.totalBytes > 0) {
848
- final sizeMB = (progress.totalBytes / 1048576).toStringAsFixed(1);
849
- _log('gray', 'download ${sizeMB}MB $pctText');
1038
+ final bucket = ((progress.fraction ?? 0) * 10).floor();
1039
+ if (bucket != _lastLoggedPctBucket) {
1040
+ _lastLoggedPctBucket = bucket;
1041
+ final sizeMB = (progress.totalBytes / 1048576).toStringAsFixed(1);
1042
+ _log('gray', 'download ${sizeMB}MB $pctText');
1043
+ }
850
1044
  }
851
1045
 
852
1046
  _state.value = _state.value.copyWith(
@@ -884,11 +1078,16 @@ class OtaOverlayManager {
884
1078
  final remaining = _minDuration - elapsed;
885
1079
  final delay = remaining.isNegative ? Duration.zero : remaining;
886
1080
 
1081
+ // On success mark ALL steps complete + flag restarting, so the steps list
1082
+ // finishes cleanly (every ✓, phase = "restarting") instead of freezing on
1083
+ // whatever step the last native progress event happened to leave active.
887
1084
  _state.value = _state.value.copyWith(
888
1085
  hasError: hasError,
889
1086
  errorText: errorText,
890
1087
  canRetry: hasError,
891
- activeStep: hasError ? _state.value.activeStep : 4,
1088
+ restarting: !hasError,
1089
+ fraction: hasError ? null : 1.0,
1090
+ activeStep: hasError ? _state.value.activeStep : _stepLabels.length,
892
1091
  );
893
1092
 
894
1093
  final entry = _entry;
@@ -1,6 +1,6 @@
1
1
  name: flutter_ota_kit
2
2
  description: Open-source, self-hosted code push (OTA updates) for Flutter Android. Patches Dart AOT libapp.so and assets on cold start with integrity checks and crash rollback.
3
- version: 0.2.0
3
+ version: 0.2.1
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@_nazmiforreal/flutter-ota",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "Command-line interface for flutter_ota_kit — an OTA code-push tool for Flutter, hot-updater compatible.",
5
5
  "bin": {
6
6
  "flutter-ota": "./bin/flutter-ota.js"