Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ viewer. Entry point: [bin/simutil.dart](bin/simutil.dart). Main app component:

## Stack (only the non-obvious bits)

- Dart `^3.11.0`. UI framework is [`nocterm`](https://nocterm.dev/) — a Flutter-like
- Dart `^3.11.0`. UI framework is `[nocterm](https://nocterm.dev/)` — a Flutter-like
component model for terminals (`StatefulComponent`, `BuildContext`, `Focusable`,
`setState`). Treat widgets as Flutter widgets.
- CLI uses `args` `CommandRunner` — see [lib/cli/simutil_command_runner.dart](lib/cli/simutil_command_runner.dart).
Expand Down Expand Up @@ -55,10 +55,12 @@ per [build.yaml](build.yaml) — do not hand-edit. CI definition lives in

## When changing code

- Bump [CHANGELOG.md](CHANGELOG.md) under `[Unreleased]` using Keep-a-Changelog
- Bump mainly user-visible changes [CHANGELOG.md](CHANGELOG.md) under `[Unreleased]` using Keep-a-Changelog
sections (`Added` / `Changed` / `Fixed`).
- Follow [.github/PULL_REQUEST_TEMPLATE.md](.github/PULL_REQUEST_TEMPLATE.md) — fill
the description and tick the Type-of-Change checkboxes.
- For UI/dialog code, always split large widget trees into smaller focused components
(prefer reusable `StatelessComponent`/`StatefulComponent` units over monolithic build methods).
- Before finishing: `dart analyze --fatal-infos` must pass.
- More: [docs/ai/contributing.md](docs/ai/contributing.md),
[docs/ai/running_tests.md](docs/ai/running_tests.md),
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.5.0] - 2026-05-02

### Changed

- Add Windows PowerShell installer command (`install.ps1`) to README installation section.

- Refactor Wi-Fi pairing flow to match Android Studio: discover pairing-code endpoints first, require code entry after selecting a discovered device, then resolve and connect to the post-pair ADB connect endpoint.

### Fixed

- Fix text color in dialogs and panels to avoid wrong overlay effect.

## [0.4.1] - 2026-04-11

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion docs/ai/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ code change itself.

## Per-PR checklist

1. **Update the changelog.** Add an entry under `[Unreleased]` in
1. **Update the changelog.** Add mainly user-visible changes under `[Unreleased]` in
[CHANGELOG.md](../../CHANGELOG.md) using the existing
[Keep-a-Changelog](https://keepachangelog.com/en/1.1.0/) sections
(`Added` / `Changed` / `Fixed` / `Removed`). One bullet per user-visible change.
Expand Down
10 changes: 2 additions & 8 deletions lib/components/error_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,9 @@ class ErrorDialog extends StatelessComponent {
return false;
},
child: Container(
width: 100,
margin: EdgeInsets.all(16),
decoration: BoxDecoration(
border: BoxBorder.all(
style: BoxBorderStyle.rounded,
color: st.error,
),
title: BorderTitle(text: title),
color: st.background,
),
decoration: st.errorDialogPanel(title),
child: Padding(
padding: EdgeInsets.all(1),
child: Column(
Expand Down
2 changes: 1 addition & 1 deletion lib/components/input_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import 'package:simutil/components/show_overlay_dialog.dart';
import 'package:simutil/components/simutil_theme.dart';

class InputDialog extends StatefulComponent {

const InputDialog({
super.key,
required this.title,
Expand Down Expand Up @@ -64,6 +63,7 @@ class _InputDialogState extends State<InputDialog> {
focused: true,
onKeyEvent: _handleKeyEvent,
child: Container(
constraints: BoxConstraints(minWidth: 50, maxWidth: 120),
margin: EdgeInsets.all(16),
decoration: st.dialogPanel(component.title),
child: Padding(
Expand Down
64 changes: 64 additions & 0 deletions lib/components/loading_state.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import 'dart:async';

import 'package:nocterm/nocterm.dart';

class LoadingState extends StatefulComponent {
const LoadingState({
this.spinnerFrames = defaultSpinnerFrames,
this.message,
this.duration = defaultDuration,
this.style = const TextStyle(fontWeight: FontWeight.dim),
});

final List<String> spinnerFrames;
final String? message;
final Duration duration;
final TextStyle style;

static const defaultDuration = Duration(milliseconds: 150);

static const defaultSpinnerFrames = [
'⠋',
'⠙',
'⠹',
'⠸',
'⠼',
'⠴',
'⠦',
'⠧',
'⠇',
'⠏',
];

@override
State<LoadingState> createState() => _LoadingState();
}

class _LoadingState extends State<LoadingState> {
Timer? _spinnerTimer;
int _spinnerIndex = 0;

@override
void initState() {
super.initState();
_spinnerTimer = Timer.periodic(component.duration, (_) {
setState(() {
_spinnerIndex = (_spinnerIndex + 1) % component.spinnerFrames.length;
});
});
}

@override
void dispose() {
_spinnerTimer?.cancel();
super.dispose();
}

@override
Component build(BuildContext context) {
final message = component.message;
final spinner = component.spinnerFrames[_spinnerIndex];
final text = message != null ? '$spinner $message' : spinner;
return Text(text, style: component.style);
}
}
110 changes: 110 additions & 0 deletions lib/components/pin_code_fields.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import 'package:nocterm/nocterm.dart';
import 'package:simutil/components/simutil_theme.dart';

class PinCodeFields extends StatelessComponent {
const PinCodeFields({
required this.label,
required this.groupFocused,
this.crossAxisAlignment = CrossAxisAlignment.center,
this.spacing = 1.0,
this.cellSpacing = 1.0,
required this.pinControllers,
required this.focusedPinIndex,
required this.onPinChanged,
required this.onPinKeyEvent,
required this.onSubmitted,
});

final String label;
final bool groupFocused;
final CrossAxisAlignment crossAxisAlignment;
final double spacing;
final double cellSpacing;
final List<TextEditingController> pinControllers;
final int focusedPinIndex;
final void Function(int index, String value) onPinChanged;
final bool Function(int index, KeyboardEvent event) onPinKeyEvent;
final VoidCallback onSubmitted;

@override
Component build(BuildContext context) {
final st = context.simutilTheme;
return Column(
crossAxisAlignment: crossAxisAlignment,
mainAxisSize: MainAxisSize.min,
children: [
Text(' $label', style: groupFocused ? st.label : st.body),
SizedBox(height: spacing),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(' ', style: st.body),
...List.generate(pinControllers.length, (index) {
return Row(
children: [
_PinCell(
controller: pinControllers[index],
focused: groupFocused && focusedPinIndex == index,
onChanged: (value) => onPinChanged(index, value),
onSubmitted: onSubmitted,
onKeyEvent: (event) => onPinKeyEvent(index, event),
),
if (index < pinControllers.length - 1)
SizedBox(width: cellSpacing),
],
);
}),
],
),
],
);
}
}

class _PinCell extends StatelessComponent {
const _PinCell({
required this.controller,
required this.focused,
required this.onChanged,
required this.onSubmitted,
required this.onKeyEvent,
});

final TextEditingController controller;
final bool focused;
final void Function(String value) onChanged;
final VoidCallback onSubmitted;
final bool Function(KeyboardEvent event) onKeyEvent;

@override
Component build(BuildContext context) {
final st = context.simutilTheme;
return Container(
width: 5,
height: 3,
child: TextField(
controller: controller,
focused: focused,
placeholder: '',
placeholderStyle: st.dimmed,
style: TextStyle(fontWeight: FontWeight.bold, color: st.onSurface),
showCursor: false,
textAlign: TextAlign.center,
onChanged: onChanged,
onSubmitted: (_) => onSubmitted(),
onKeyEvent: onKeyEvent,
decoration: InputDecoration(
border: BoxBorder.all(
style: BoxBorderStyle.rounded,
color: st.outline,
),
focusedBorder: BoxBorder.all(
style: BoxBorderStyle.rounded,
color: st.primary,
),
contentPadding: EdgeInsets.zero,
),
),
);
}
}
15 changes: 12 additions & 3 deletions lib/components/simutil_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ class SimutilTheme {

TextStyle get body => const TextStyle();

TextStyle get dimmed => const TextStyle(fontWeight: FontWeight.dim);
TextStyle get dimmed =>
const TextStyle(fontWeight: FontWeight.dim, color: Color.defaultColor);

TextStyle get bold => const TextStyle(fontWeight: FontWeight.bold);
TextStyle get bold =>
const TextStyle(fontWeight: FontWeight.bold, color: Color.defaultColor);

TextStyle get selected => const TextStyle(reverse: true);
TextStyle get selected =>
const TextStyle(reverse: true, color: Color.defaultColor);

TextStyle get label => TextStyle(color: primary);

Expand Down Expand Up @@ -75,6 +78,12 @@ class SimutilTheme {
color: Color.defaultColor
);

BoxDecoration errorDialogPanel(String title) => BoxDecoration(
border: BoxBorder.all(style: BoxBorderStyle.rounded, color: error),
title: BorderTitle(text: title),
color: Color.defaultColor
);

static TuiThemeData resolveTheme(String name) {
return switch (name) {
'light' => TuiThemeData.light,
Expand Down
6 changes: 6 additions & 0 deletions lib/models/adb_connect_result.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AdbConnectResult {
const AdbConnectResult({required this.success, required this.message});

final bool success;
final String message;
}
13 changes: 13 additions & 0 deletions lib/models/wifi_pairing_device.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class WifiPairingDevice {
const WifiPairingDevice({
required this.name,
required this.host,
required this.port,
});

final String name;
final String host;
final int port;

String get hostPort => '$host:$port';
}
6 changes: 6 additions & 0 deletions lib/models/wireless_connect_request.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class WirelessConnectRequest {
const WirelessConnectRequest({required this.host, this.pairingCode});

final String host;
final String? pairingCode;
}
11 changes: 11 additions & 0 deletions lib/models/wireless_pairing_info.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class WirelessPairingInfo {
const WirelessPairingInfo({
required this.deviceIp,
required this.defaultPort,
required this.supportsWirelessDebugging,
});

final String deviceIp;
final int defaultPort;
final bool supportsWirelessDebugging;
}
12 changes: 6 additions & 6 deletions lib/plugins/adb_tools/adb_tools_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ enum AdbToolOption {
label: 'Connect via IP',
description: 'Connect to already-paired device (e.g., 192.168.1.100:5555)',
),
connectViaPairCode(
label: 'Connect via Pair Code',
description: 'Pair with 6-digit code (Android 11+)',
pairWithPairingCode(
label: 'Pair using Pairing Code',
description: 'Pair using pairing code for wireless debugging (Android 11+)',
),
connectViaQr(
label: 'Connect via QR Code',
description: 'Scan QR code for wireless debugging (Android 11+)',
pairWithQrCode(
label: 'Pair using QR Code',
description: 'Pair using QR code for wireless debugging (Android 11+)',
);

const AdbToolOption({required this.label, required this.description});
Expand Down
2 changes: 1 addition & 1 deletion lib/plugins/adb_tools/qr_connect_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class _QrConnectDialogState extends State<QrConnectDialog> {
child: Container(
width: 100,
margin: EdgeInsets.all(4),
decoration: st.dialogPanel('QR Code Pairing'),
decoration: st.dialogPanel('Pairing with QR Code'),
child: Padding(
padding: EdgeInsets.all(1),
child: Focusable(
Expand Down
Loading