Commit 84fd69a4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: parallel runs made visible, real deploys, and twenty daily-use features

The two that hurt
- Two chats on one repo never ran together, and the app showed the waiting
  one as "working". The server now allows several runs per repo (Settings
  sets how many), and a queued run says what it is waiting for, everywhere
  it appears.
- Deploy did nothing. CapRover rejects the call the app was making, so the
  button reported success and shipped nothing; the fix builds the head of
  the app's branch. Apps with no repository say so instead of pretending.

Runs
 1. Several runs at once, per repo and overall, both adjustable in Settings.
 2. A "running now" sheet: everything working or waiting, with why, and a
    stop button for each. The Chats tab carries a live count.
 3. Type while a run is going and the message queues — it sends itself the
    moment that run ends.
 4. Fork a chat: same repo, last prompt ready to edit, both run in parallel.

Chats
 5. Dictation, with a language picker on long-press (an Arabic sentence in
    an English recogniser is nonsense).
 6. A file picker that inserts a repo path into the prompt.
 7. Search inside conversations — titles, prompts and answers — not just
    titles, with the matching line shown.
 8. Each finished run shows how long it took, and its exit code if it failed.
 9. Pin a chat (long-press) to keep it at the top.
10. Tapping a finished-run notification opens that chat, even from cold.

Git
11. A commit message written from the actual diff, then edited before use.
12. The branch chip switches branch, and creates one.
13. Commit history, each commit openable as its own patch.

Files
14. Content search across the repo, opening the file at the matching line.
15. Starred files and recents per project.
16. "Diff vs HEAD" on the open file.

Deploy
17. Deploy = build the latest commit on the branch (the fix above).
18. Roll back to a version CapRover still has the image for.
19. Restart, and scale instances (zero is a deliberate stop).
20. Box health in Settings: CPU, memory, disk, and what is running.

Version 1.5.0+15.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 90a71482
......@@ -7,6 +7,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Dictating a prompt instead of typing it on a phone. -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<application
android:label="Son of Anton"
......@@ -50,5 +52,11 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Android 11+ hides other packages unless they are declared here, and
speech recognition lives in one of them: without this, dictation
reports "not available" on every modern phone. -->
<intent>
<action android:name="android.speech.RecognitionService"/>
</intent>
</queries>
</manifest>
......@@ -112,6 +112,9 @@ class ChatMessage {
this.routedAuto = false,
this.routedReason,
this.wasPlan = false,
this.startedAt,
this.finishedAt,
this.queuedReason,
});
final String id;
......@@ -135,6 +138,21 @@ class ChatMessage {
/// nothing. The app offers to approve it.
final bool wasPlan;
/// When the run actually started and stopped — the difference is the only
/// honest "how long did that take".
final DateTime? startedAt;
final DateTime? finishedAt;
/// Set while a run is waiting its turn, saying what it waits for. Null once
/// it is running: waiting and working used to look identical.
final String? queuedReason;
bool get isQueued => (queuedReason ?? '').isNotEmpty;
Duration? get took => (startedAt == null || finishedAt == null)
? null
: finishedAt!.difference(startedAt!);
bool get isUser => role == 'user';
factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage(
......@@ -151,6 +169,9 @@ class ChatMessage {
routedAuto: _b(j['routedAuto']),
routedReason: j['routedReason'] == null ? null : _s(j['routedReason']),
wasPlan: _b(j['wasPlan']),
startedAt: j['startedAt'] == null ? null : _dt(j['startedAt']),
finishedAt: j['finishedAt'] == null ? null : _dt(j['finishedAt']),
queuedReason: j['queuedReason'] == null ? null : _s(j['queuedReason']),
);
ChatMessage copyWith({String? content, RunStatus? status}) => ChatMessage(
......@@ -167,6 +188,9 @@ class ChatMessage {
routedAuto: routedAuto,
routedReason: routedReason,
wasPlan: wasPlan,
startedAt: startedAt,
finishedAt: finishedAt,
queuedReason: queuedReason,
);
}
......@@ -449,6 +473,229 @@ class ZipInfo {
);
}
// --- what the box is doing ---------------------------------------------------
/// One run: working, or waiting to start and saying what for.
class RunInfo {
const RunInfo({
required this.id,
required this.project,
required this.prompt,
required this.queued,
this.reason = '',
this.elapsedSec = 0,
});
final String id;
final String project;
final String prompt;
final bool queued;
final String reason;
final int elapsedSec;
factory RunInfo.fromJson(Map<String, dynamic> j) => RunInfo(
id: _s(j['id']),
project: _s(j['project']),
prompt: _s(j['prompt']),
queued: _s(j['state']) == 'queued',
reason: _s(j['reason']),
elapsedSec: _i(j['elapsedSec']),
);
}
/// How much the server will run at once, and the ceilings it will not exceed.
class Limits {
const Limits({
required this.maxConcurrent,
required this.maxPerProject,
this.ceilingConcurrent = 8,
this.ceilingPerProject = 4,
});
final int maxConcurrent;
final int maxPerProject;
final int ceilingConcurrent;
final int ceilingPerProject;
factory Limits.fromJson(Map<String, dynamic> j) {
final c = j['ceiling'];
final ceiling = c is Map ? c.cast<String, dynamic>() : const <String, dynamic>{};
return Limits(
maxConcurrent: _i(j['maxConcurrent'], 4),
maxPerProject: _i(j['maxPerProject'], 2),
ceilingConcurrent: _i(ceiling['maxConcurrent'], 8),
ceilingPerProject: _i(ceiling['maxPerProject'], 4),
);
}
}
class RunSnapshot {
const RunSnapshot({this.running = const [], this.queued = const [], required this.limits});
final List<RunInfo> running;
final List<RunInfo> queued;
final Limits limits;
int get total => running.length + queued.length;
factory RunSnapshot.fromJson(Map<String, dynamic> j) => RunSnapshot(
running: _list(j['running'], RunInfo.fromJson),
queued: _list(j['queued'], RunInfo.fromJson),
limits: Limits.fromJson((j['limits'] as Map?)?.cast<String, dynamic>() ?? const {}),
);
}
class SystemHealth {
const SystemHealth({
required this.cpus,
required this.loadPerCore,
required this.memUsedPct,
required this.memTotalBytes,
required this.diskUsedPct,
required this.diskFreeBytes,
required this.uptimeSec,
required this.running,
required this.queued,
});
final int cpus;
final double loadPerCore;
final int memUsedPct;
final int memTotalBytes;
final int diskUsedPct;
final int diskFreeBytes;
final int uptimeSec;
final int running;
final int queued;
factory SystemHealth.fromJson(Map<String, dynamic> j) {
Map<String, dynamic> sub(String k) =>
(j[k] as Map?)?.cast<String, dynamic>() ?? const <String, dynamic>{};
final load = sub('load');
final mem = sub('memory');
final disk = sub('disk');
final runs = sub('runs');
return SystemHealth(
cpus: _i(j['cpus'], 1),
loadPerCore: double.tryParse('${load['perCore'] ?? 0}') ?? 0,
memUsedPct: _i(mem['usedPct']),
memTotalBytes: _i(mem['totalBytes']),
diskUsedPct: _i(disk['usedPct']),
diskFreeBytes: _i(disk['freeBytes']),
uptimeSec: _i(j['uptimeSec']),
running: _i(runs['running']),
queued: _i(runs['queued']),
);
}
}
// --- git ---------------------------------------------------------------------
class GitBranch {
const GitBranch({
required this.name,
required this.current,
this.subject = '',
this.upstream = '',
this.at,
});
final String name;
final bool current;
final String subject;
final String upstream;
final DateTime? at;
factory GitBranch.fromJson(Map<String, dynamic> j) => GitBranch(
name: _s(j['name']),
current: _b(j['current']),
subject: _s(j['subject']),
upstream: _s(j['upstream']),
at: (j['at'] == null || _s(j['at']).isEmpty) ? null : _dt(j['at']),
);
}
// --- search ------------------------------------------------------------------
/// One matching line from a content search.
class GrepHit {
const GrepHit({required this.path, required this.name, required this.line, required this.text});
final String path;
final String name;
final int line;
final String text;
factory GrepHit.fromJson(Map<String, dynamic> j) => GrepHit(
path: _s(j['path']),
name: _s(j['name']),
line: _i(j['line']),
text: _s(j['text']),
);
}
/// Where a chat matched: its title, something you asked, or something Claude said.
class ChatMatch {
const ChatMatch({required this.kind, required this.text, this.taskId = ''});
final String kind;
final String text;
final String taskId;
String get label => switch (kind) {
'you' => 'you',
'claude' => 'Claude',
_ => 'title',
};
factory ChatMatch.fromJson(Map<String, dynamic> j) => ChatMatch(
kind: _s(j['kind']),
text: _s(j['text']),
taskId: _s(j['taskId']),
);
}
class ChatHit {
const ChatHit({
required this.id,
required this.title,
required this.project,
required this.matches,
this.updatedAt,
});
final String id;
final String title;
final String project;
final List<ChatMatch> matches;
final DateTime? updatedAt;
factory ChatHit.fromJson(Map<String, dynamic> j) => ChatHit(
id: _s(j['id']),
title: _s(j['title'], 'Chat'),
project: _s(j['project']),
matches: _list(j['matches'], ChatMatch.fromJson),
updatedAt: j['updatedAt'] == null ? null : _dt(j['updatedAt']),
);
}
/// A version CapRover still holds an image for — what a rollback goes back to.
class CapVersion {
const CapVersion({required this.version, required this.image, this.gitHash = '', this.at});
final int version;
final String image;
final String gitHash;
final DateTime? at;
factory CapVersion.fromJson(Map<String, dynamic> j) => CapVersion(
version: _i(j['version']),
image: _s(j['image']),
gitHash: _s(j['gitHash']),
at: (j['at'] == null || _s(j['at']).isEmpty) ? null : _dt(j['at']),
);
}
class Commit {
const Commit({required this.sha, required this.message, required this.author, required this.date});
final String sha;
......@@ -583,6 +830,8 @@ class CapApp {
this.domains = const [],
this.envKeys = const [],
this.volumes = const [],
this.canDeployFromGit = false,
this.versions = const [],
});
final String name;
......@@ -604,6 +853,14 @@ class CapApp {
final List<CapEnvKey> envKeys;
final List<({String path, String volume})> volumes;
/// Whether "deploy the latest commit" is even possible: the app must have a
/// repository configured and a build webhook for it.
final bool canDeployFromGit;
/// Versions CapRover still holds images for — what a rollback can return to.
/// Only populated for a single app's detail, never the list.
final List<CapVersion> versions;
/// An app scaled to zero is intentionally stopped, which is different from
/// broken — the UI should not shout about it.
bool get stopped => instanceCount == 0;
......@@ -624,6 +881,8 @@ class CapApp {
forceSsl: _b(j['forceSsl']),
websocket: _b(j['websocket']),
domains: _strings(j['domains']),
canDeployFromGit: _b(j['canDeployFromGit']),
versions: _list(j['versions'], CapVersion.fromJson),
envKeys: _list(j['envKeys'], CapEnvKey.fromJson),
volumes: (j['volumes'] is List)
? [
......
......@@ -191,6 +191,10 @@ class SonApi {
Future<void> stopChat(String id) async => _send('POST', '/api/chats/$id/stop');
/// Cancels one run by task id — what the "running now" list acts on, where a
/// run is known by its task rather than by which chat it belongs to.
Future<void> cancelRun(String taskId) async => _send('DELETE', '/api/tasks/$taskId');
/// Returns the task id of the queued run, which is what the stream follows.
Future<String> sendMessage(
String chatId, {
......@@ -417,6 +421,57 @@ class SonApi {
Future<void> gitDiscard(String project, String file) async =>
_act(_send('POST', '/api/git/discard', body: {'project': project, 'path': file}));
Future<List<GitBranch>> gitBranches(String project) async {
final j = _obj(await _get('/api/git/branches', {'repo': project}));
final l = j['branches'];
return l is List
? [
for (final e in l)
if (e is Map) GitBranch.fromJson(e.cast<String, dynamic>()),
]
: const [];
}
Future<void> gitCheckout(String project, String branch, {bool create = false}) async =>
_act(_send('POST', '/api/git/checkout',
body: {'repo': project, 'branch': branch, 'create': create}));
/// One commit's patch.
Future<String> gitShow(String project, String sha) async {
final j = _obj(await _get('/api/git/show', {'repo': project, 'sha': sha}));
return (j['diff'] ?? '').toString();
}
/// A commit message written from the real diff by the server's claude.
Future<String> gitCommitMessage(String project) async {
final j = await _act(_send('POST', '/api/git/commit-message', body: {'repo': project}));
return (j['message'] ?? '').toString();
}
/// Content search: what filename search cannot answer.
Future<List<GrepHit>> grep(
String project,
String query, {
String path = '.',
bool caseSensitive = false,
bool regex = false,
}) async {
final j = _obj(await _get('/api/files/grep', {
'project': project,
'q': query,
'path': path,
if (caseSensitive) 'caseSensitive': 'true',
if (regex) 'regex': 'true',
}));
final l = j['results'];
return l is List
? [
for (final e in l)
if (e is Map) GrepHit.fromJson(e.cast<String, dynamic>()),
]
: const [];
}
// --- caprover -------------------------------------------------------------
//
// The phone never sees the CapRover password: it authenticates to DevPilot,
......@@ -431,10 +486,22 @@ class SonApi {
return CapApp.fromJson((j['app'] as Map).cast<String, dynamic>());
}
/// Rebuild from the app's configured git repo, or deploy a specific image.
/// Build and deploy the head of the app's branch, or deploy a specific image.
/// Wrapped in _act: "no repository configured" is an answer the user needs to
/// see, and the old call discarded it.
Future<void> capDeploy(String name, {String? image}) async =>
_send('POST', '/api/caprover/apps/$name/deploy',
body: {if (image != null && image.isNotEmpty) 'image': image});
_act(_send('POST', '/api/caprover/apps/$name/deploy',
body: {if (image != null && image.isNotEmpty) 'image': image}));
/// Back to a version CapRover still holds the image for.
Future<void> capRollback(String name, int version) async =>
_act(_send('POST', '/api/caprover/apps/$name/rollback', body: {'version': version}));
Future<void> capRestart(String name) async =>
_act(_send('POST', '/api/caprover/apps/$name/restart'));
Future<void> capScale(String name, int instances) async =>
_act(_send('POST', '/api/caprover/apps/$name/scale', body: {'instances': instances}));
Future<void> capSetRepo(
String name, {
......@@ -468,6 +535,36 @@ class SonApi {
: const [];
}
// --- what the box is doing --------------------------------------------------
/// Everything running or waiting, and the limits that decide which is which.
Future<RunSnapshot> runs() async => RunSnapshot.fromJson(_obj(await _get('/api/runs')));
Future<Limits> limits() async => Limits.fromJson(_obj(await _get('/api/limits')));
Future<Limits> setLimits({int? maxConcurrent, int? maxPerProject}) async =>
Limits.fromJson(await _act(_send('PATCH', '/api/limits', body: {
if (maxConcurrent != null) 'maxConcurrent': maxConcurrent,
if (maxPerProject != null) 'maxPerProject': maxPerProject,
})));
Future<SystemHealth> system() async => SystemHealth.fromJson(_obj(await _get('/api/system')));
/// Searches inside conversations — titles, prompts and answers.
Future<List<ChatHit>> searchChats(String query, {String? project}) async {
final j = _obj(await _get('/api/chats/search', {
'q': query,
if (project != null && project.isNotEmpty) 'project': project,
}));
final l = j['results'];
return l is List
? [
for (final e in l)
if (e is Map) ChatHit.fromJson(e.cast<String, dynamic>()),
]
: const [];
}
// --- usage ----------------------------------------------------------------
/// [force] bypasses the server's 60s cache — the Settings refresh button.
......
......@@ -21,6 +21,34 @@ abstract final class Notifications {
styleInformation: BigTextStyleInformation(''),
);
/// What to do when one of these notifications is tapped. Set by the shell
/// once it can navigate.
static void Function(String chatId)? onOpenChat;
/// A tap that arrived before anything could handle it — the app was not
/// running when the notification was pressed.
static String? _pending;
static void _tapped(NotificationResponse r) {
final id = r.payload ?? '';
if (id.isEmpty) return;
final open = onOpenChat;
if (open == null) {
_pending = id;
return;
}
open(id);
}
/// Delivers a tap that happened while the app was closed. Called once the UI
/// exists; without it, a cold start from a notification lands on the tab you
/// last had open instead of the run you were told about.
static void flushPending() {
final id = _pending;
_pending = null;
if (id != null && id.isNotEmpty) onOpenChat?.call(id);
}
static Future<void> init() async {
if (_ready) return;
try {
......@@ -28,7 +56,13 @@ abstract final class Notifications {
const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
onDidReceiveNotificationResponse: _tapped,
);
final launch = await _plugin.getNotificationAppLaunchDetails();
if (launch?.didNotificationLaunchApp ?? false) {
final id = launch?.notificationResponse?.payload ?? '';
if (id.isNotEmpty) _pending = id;
}
await _plugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
......@@ -43,14 +77,19 @@ abstract final class Notifications {
required String chatTitle,
required String project,
required bool ok,
String chatId = '',
}) async {
if (!_ready) return;
try {
await _plugin.show(
chatTitle.hashCode & 0x7fffffff,
// Keyed by chat, so a second run in the same chat replaces its notice
// rather than stacking another one.
(chatId.isEmpty ? chatTitle : chatId).hashCode & 0x7fffffff,
ok ? 'Finished · $project' : 'Failed · $project',
chatTitle,
const NotificationDetails(android: _channel),
// Carries which chat to open when it is tapped.
payload: chatId,
);
} catch (e) {
debugPrint('notify failed: $e');
......
......@@ -5,7 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart';
/// Shown in Settings. Kept beside pubspec's `version:` by hand — reading it at
/// runtime would need package_info_plus for one string.
const kAppVersion = '1.4.0';
const kAppVersion = '1.5.0';
/// On-device conveniences: things that make the app nicer to come back to and
/// that the server has no reason to know.
......@@ -65,6 +65,50 @@ class Prefs {
static Future<void> setFileSort(String v) async => (await _p).setString('file_sort', v);
// --- chats ------------------------------------------------------------------
/// Chats kept at the top of the list. Ids, so a rename does not lose the pin.
static Future<Set<String>> pinnedChats() async =>
((await _p).getStringList('pinned_chats') ?? const []).toSet();
static Future<Set<String>> togglePinnedChat(String id) async {
final p = await _p;
final next = ((p.getStringList('pinned_chats') ?? const <String>[]).toSet());
if (!next.remove(id)) next.add(id);
await p.setStringList('pinned_chats', next.toList());
return next;
}
/// The language dictation listens for. Empty means the phone's own default,
/// which is right until you speak Arabic to an English phone.
static Future<String> dictationLocale() async => (await _p).getString('dictation_locale') ?? '';
static Future<void> setDictationLocale(String id) async =>
(await _p).setString('dictation_locale', id);
// --- files ------------------------------------------------------------------
static Future<List<String>> starredFiles(String project) async =>
(await _p).getStringList('starred.$project') ?? const [];
static Future<List<String>> toggleStarredFile(String project, String path) async {
final p = await _p;
final list = [...(p.getStringList('starred.$project') ?? const <String>[])];
if (!list.remove(path)) list.insert(0, path);
await p.setStringList('starred.$project', list.take(100).toList());
return list;
}
static Future<List<String>> recentFiles(String project) async =>
(await _p).getStringList('recent.$project') ?? const [];
/// Most recent first, no duplicates, capped — a trail, not a history.
static Future<void> pushRecentFile(String project, String path) async {
final p = await _p;
final list = [...(p.getStringList('recent.$project') ?? const <String>[])]..remove(path);
await p.setStringList('recent.$project', [path, ...list].take(30).toList());
}
// --- display ----------------------------------------------------------------
/// Keep the screen on while a run you are watching is live. On by default:
......
......@@ -119,6 +119,28 @@ final gitStatusProvider = FutureProvider<List<RepoStatus>>((ref) async {
return ref.watch(apiProvider).gitStatusAll();
});
/// CPU, memory and disk of the box — "is the server struggling?" without ssh.
final systemProvider =
FutureProvider.autoDispose<SystemHealth>((ref) => ref.watch(apiProvider).system());
/// How much the server will run at once. Changeable from Settings.
final limitsProvider = FutureProvider.autoDispose<Limits>((ref) => ref.watch(apiProvider).limits());
/// What the box is working on, polled while something is watching. This is what
/// lets the app say "queued behind another run in clubphp" instead of showing a
/// spinner that means nothing.
final runsProvider = StreamProvider.autoDispose<RunSnapshot>((ref) async* {
final api = ref.watch(apiProvider);
while (true) {
try {
yield await api.runs();
} catch (_) {
// Keep the last good snapshot: a dropped poll is not news.
}
await Future<void>.delayed(const Duration(seconds: 5));
}
});
// --- chats ------------------------------------------------------------------
/// The chat list, polled while anything is running so the tab reflects work
......
......@@ -52,6 +52,7 @@ class _Watcher extends TaskHandler {
for (final id in finished) {
final title = _titles[id] ?? 'Task finished';
await Notifications.runFinished(
chatId: id,
chatTitle: title,
project: _projects[id] ?? '',
ok: _ok[id] ?? true,
......
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../../core/api/models.dart';
import '../../core/api/son_api.dart';
......@@ -218,30 +219,192 @@ class _AppCardState extends ConsumerState<_AppCard> {
}
}
/// Redeploy is confirmed by typing nothing — but it IS confirmed. It restarts
/// a live service, and the whole point of this tab is that you are not at a
/// desk when you press it.
/// Deploy means "build the newest commit on the branch and ship it".
///
/// It used to POST an empty body to CapRover's appData endpoint, which that
/// endpoint rejects outright — so this button reported success and deployed
/// nothing. It now triggers the repo's build webhook, the same thing
/// CapRover's own Force Build uses. Confirmed, because it replaces a live
/// service while you are nowhere near a desk.
Future<void> _deploy() async {
final fromGit = a.canDeployFromGit;
final branch = a.repo.branch.isEmpty ? 'the default branch' : a.repo.branch;
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text('Redeploy ${a.name}?'),
title: Text(fromGit ? 'Deploy latest to ${a.name}?' : 'Restart ${a.name}?'),
content: Text(
a.repo.isSet
? 'Rebuilds from ${a.repo.url} (${a.repo.branch.isEmpty ? 'default branch' : a.repo.branch}) and restarts the app.'
: 'Restarts the app from its current image. Anything running inside it is killed.',
fromGit
? 'Builds the newest commit on $branch of ${a.repo.url} and replaces what is running.'
: a.repo.isSet
? 'This app has a repository but no build webhook yet — open Source, save it once, and Deploy will build the branch. For now this restarts the running image.'
: 'This app runs a fixed image, so there is no branch to build. This restarts it.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Redeploy', style: TextStyle(color: Ember.flame)),
child: Text(fromGit ? 'Deploy latest' : 'Restart',
style: const TextStyle(color: Ember.flame)),
),
],
),
);
if (ok != true) return;
await _run(() => _api.capDeploy(a.name), 'Deploy triggered');
await _run(
() => fromGit ? _api.capDeploy(a.name) : _api.capRestart(a.name),
fromGit ? 'Building the latest commit — watch the build log' : 'Restarting',
);
}
/// Back to a version CapRover still keeps the image for. The list response
/// carries no history, so the detail is fetched only when it is needed.
Future<void> _rollback() async {
try {
final detail = await _api.capApp(a.name);
final versions = detail.versions
.where((v) => v.image.isNotEmpty && v.version != a.deployedVersion)
.toList();
if (!mounted) return;
if (versions.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No earlier version still has an image on the box')),
);
return;
}
final picked = await showModalBottomSheet<CapVersion>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 14),
Text('Roll ${a.name} back to',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
const SizedBox(height: 10),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: versions.length,
itemBuilder: (_, i) {
final v = versions[i];
return ListTile(
dense: true,
leading: const Icon(Icons.history, size: 18, color: Ember.textDim),
title: Text('v${v.version}',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
subtitle: Text(
[
if (v.gitHash.isNotEmpty) v.gitHash.substring(0, 7),
if (v.at != null) timeago.format(v.at!),
].join(' · '),
style: const TextStyle(fontSize: 11, color: Ember.textFaint),
),
onTap: () => Navigator.pop(sheet, v),
);
},
),
),
const SizedBox(height: 8),
],
),
),
);
if (picked == null || !mounted) return;
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text('Roll back to v${picked.version}?'),
content: Text('${a.name} goes back to the image built for v${picked.version}. '
'The newer version stays in the list and can be deployed again.'),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Roll back', style: TextStyle(color: Ember.bad)),
),
],
),
);
if (ok != true) return;
await _run(() => _api.capRollback(a.name, picked.version), 'Rolled back to v${picked.version}');
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
}
Future<void> _restart() async {
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text('Restart ${a.name}?'),
content: const Text('The running image is deployed again. Anything in flight inside the '
'container is killed.'),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Restart', style: TextStyle(color: Ember.flame)),
),
],
),
);
if (ok != true) return;
await _run(() => _api.capRestart(a.name), 'Restarting');
}
/// How many containers run. Zero is a deliberate stop, which is why it is
/// allowed and spelled out rather than hidden.
Future<void> _scale() async {
var n = a.instanceCount;
final picked = await showDialog<int>(
context: context,
builder: (c) => StatefulBuilder(
builder: (c, setDialog) => AlertDialog(
title: Text('Instances of ${a.name}'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: n > 0 ? () => setDialog(() => n--) : null,
),
SizedBox(
width: 56,
child: Text('$n',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w700)),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: n < 10 ? () => setDialog(() => n++) : null,
),
],
),
Text(
n == 0
? 'Zero stops the app without deleting it.'
: 'Running $n container${n == 1 ? '' : 's'}.',
style: const TextStyle(fontSize: 11.5, color: Ember.textFaint),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(c), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(c, n), child: const Text('Apply')),
],
),
),
);
if (picked == null || picked == a.instanceCount) return;
await _run(() => _api.capScale(a.name, picked), 'Scaled to $picked');
}
Future<void> _editRepo() async {
......@@ -469,12 +632,25 @@ class _AppCardState extends ConsumerState<_AppCard> {
else
Row(
children: [
_act(Icons.rocket_launch_outlined, 'Redeploy', _deploy),
_act(Icons.rocket_launch_outlined, a.canDeployFromGit ? 'Deploy' : 'Restart',
_deploy),
_act(Icons.commit, 'Source', _editRepo),
_act(Icons.construction, 'Build log',
() => _showLogs(LogKind.build)),
_act(Icons.terminal, 'Live log',
() => _showLogs(LogKind.runtime)),
_act(Icons.construction, 'Build log', () => _showLogs(LogKind.build)),
_act(Icons.terminal, 'Live log', () => _showLogs(LogKind.runtime)),
PopupMenuButton<String>(
tooltip: 'More',
icon: const Icon(Icons.more_vert, size: 18, color: Ember.textDim),
onSelected: (v) => switch (v) {
'rollback' => _rollback(),
'restart' => _restart(),
_ => _scale(),
},
itemBuilder: (_) => const [
PopupMenuItem(value: 'rollback', child: Text('Roll back a version')),
PopupMenuItem(value: 'restart', child: Text('Restart')),
PopupMenuItem(value: 'scale', child: Text('Scale instances')),
],
),
],
),
const SizedBox(height: 4),
......
......@@ -4,6 +4,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:speech_to_text/speech_to_text.dart' as stt;
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../core/api/models.dart';
......@@ -71,6 +72,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
bool _keepAwake = true;
bool _awake = false;
/// Typed while a run was still going: sent the moment that run ends, so the
/// next instruction does not have to wait for you to notice it finished.
String? _queuedFollowUp;
final _speech = stt.SpeechToText();
bool _listening = false;
String _dictationLocale = '';
@override
void initState() {
super.initState();
......@@ -85,6 +94,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
_keepAwake = v;
if (mounted) _syncAwake(_chat?.isRunning ?? false);
});
Prefs.dictationLocale().then((v) => _dictationLocale = v);
}
/// A draft handed in (Files → "Ask Claude about this") wins over a saved one:
......@@ -147,6 +157,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
void dispose() {
_scroll.removeListener(_onScroll);
_stopLive();
if (_listening) _speech.cancel();
_draftSave?.cancel();
_input.removeListener(_onDraftChanged);
// Saved on the way out as well, so text typed in the last 400 ms survives.
......@@ -178,10 +189,18 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
if (wasRunning && !c.isRunning) {
_liveText = '';
Notifications.runFinished(
chatId: widget.chatId,
chatTitle: c.title,
project: c.project,
ok: c.status == RunStatus.completed,
);
final pending = _queuedFollowUp;
if (pending != null && pending.isNotEmpty) {
_queuedFollowUp = null;
// Not awaited inside _load: this starts the next run, which calls
// _load again when it lands.
unawaited(_sendText(pending));
}
}
_syncAwake(c.isRunning);
if (c.isRunning) {
......@@ -285,11 +304,26 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
Future<void> _send() async {
final text = _input.text.trim();
if (text.isEmpty || _sending) return;
// A run is still going: hold this one and send it the moment that ends,
// rather than making you watch for the right moment to press send.
if (_chat?.isRunning ?? false) {
setState(() => _queuedFollowUp = text);
_input.clear();
Prefs.setDraft(widget.chatId, '');
HapticFeedback.lightImpact();
_toast('Queued — it sends when this run finishes');
return;
}
await _sendText(text);
}
Future<void> _sendText(String text) async {
if (_sending) return;
setState(() => _sending = true);
try {
await _api.sendMessage(
widget.chatId,
text: text,
text: text.trim(),
model: _model,
effort: _effort,
attachments: _pending.map((a) => a.name).toList(),
......@@ -398,6 +432,138 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
await Prefs.setQuickPrompts(next);
}
/// Dictation. Speaking a paragraph beats typing it on a phone, and this app
/// is used one-handed while something else is running.
Future<void> _dictate() async {
if (_listening) {
await _speech.stop();
if (mounted) setState(() => _listening = false);
return;
}
final ready = await _speech.initialize(
onStatus: (s) {
if ((s == 'done' || s == 'notListening') && mounted) setState(() => _listening = false);
},
onError: (e) {
if (!mounted) return;
setState(() => _listening = false);
// "error_no_match" is simply silence; saying so is noise.
if (e.errorMsg != 'error_no_match') _toast('Dictation: ${e.errorMsg}', bad: true);
},
);
if (!ready) {
if (mounted) _toast('This phone has no speech recognition available.', bad: true);
return;
}
// Keep what was already typed and append to it, rather than replacing it.
final base = _input.text.trim();
if (mounted) setState(() => _listening = true);
HapticFeedback.mediumImpact();
await _speech.listen(
onResult: (r) {
final heard = r.recognizedWords;
if (heard.isEmpty) return;
final text = base.isEmpty ? heard : '$base $heard';
_input.value =
TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length));
},
listenOptions: stt.SpeechListenOptions(
listenMode: stt.ListenMode.dictation,
partialResults: true,
cancelOnError: true,
autoPunctuation: true,
localeId: _dictationLocale.isEmpty ? null : _dictationLocale,
// Stops on its own after a few seconds of silence, so a forgotten
// microphone does not sit open.
pauseFor: const Duration(seconds: 4),
listenFor: const Duration(minutes: 3),
),
);
}
/// Long-press the mic: which language it listens for. An Arabic sentence
/// dictated into an English recogniser comes out as nonsense.
Future<void> _pickDictationLocale() async {
if (!await _speech.initialize()) {
if (mounted) _toast('This phone has no speech recognition available.', bad: true);
return;
}
final locales = await _speech.locales();
if (!mounted) return;
final picked = await showModalBottomSheet<String>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (sheet) => SafeArea(
child: DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
builder: (_, ctrl) => ListView.builder(
controller: ctrl,
itemCount: locales.length + 1,
itemBuilder: (_, i) {
if (i == 0) {
return ListTile(
title: const Text('Phone default', style: TextStyle(fontSize: 14)),
trailing: _dictationLocale.isEmpty
? const Icon(Icons.check, color: Ember.flame, size: 18)
: null,
onTap: () => Navigator.pop(sheet, ''),
);
}
final l = locales[i - 1];
return ListTile(
title: Text(l.name, style: const TextStyle(fontSize: 14)),
subtitle: Text(l.localeId,
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
trailing: l.localeId == _dictationLocale
? const Icon(Icons.check, color: Ember.flame, size: 18)
: null,
onTap: () => Navigator.pop(sheet, l.localeId),
);
},
),
),
),
);
if (picked == null || !mounted) return;
setState(() => _dictationLocale = picked);
await Prefs.setDictationLocale(picked);
}
/// Put a repo path into the prompt. Typing one on a phone is miserable, and
/// a wrong path sends the agent looking in the wrong place.
Future<void> _mentionFile() async {
final project = _chat?.project ?? '';
if (project.isEmpty) return;
final picked = await showModalBottomSheet<String>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => _FilePickSheet(project: project, api: _api),
);
if (picked == null || !mounted) return;
_insertQuick('`$picked`');
}
/// A second line of attack on the same repo: a new chat, same project, with
/// the last thing asked ready to edit. Both then run at once.
Future<void> _fork() async {
final c = _chat;
if (c == null) return;
try {
final fresh = await ref
.read(chatsProvider.notifier)
.create(c.project, model: _model, effort: _effort);
if (!mounted) return;
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => ChatScreen(chatId: fresh.id, draft: _lastPrompt?.content ?? ''),
));
} on ApiException catch (e) {
if (mounted) _toast(e.message, bad: true);
}
}
Future<void> _exportPdf() async {
final c = _chat;
if (c == null) return;
......@@ -541,7 +707,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
onPressed: _togglePlanFirst,
),
PopupMenuButton<String>(
onSelected: (v) => v == 'pdf' ? _exportPdf() : _rename(),
onSelected: (v) => switch (v) {
'pdf' => _exportPdf(),
'fork' => _fork(),
_ => _rename(),
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'rename',
......@@ -551,6 +721,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
leading: Icon(Icons.edit_outlined),
title: Text('Rename')),
),
PopupMenuItem(
value: 'fork',
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.alt_route),
title: Text('Fork into a new chat')),
),
PopupMenuItem(
value: 'pdf',
child: ListTile(
......@@ -611,6 +789,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
onRemoveAttachment: (a) => setState(() => _pending.remove(a)),
onSend: _send,
onStop: _stop,
queuedFollowUp: _queuedFollowUp,
onCancelFollowUp: () => setState(() => _queuedFollowUp = null),
listening: _listening,
onDictate: _dictate,
onDictateLanguage: _pickDictationLocale,
onMentionFile: _mentionFile,
focusNode: _focus,
quick: _quick,
onQuick: _insertQuick,
......@@ -759,6 +943,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
}
/// "820ms", "12s", "4m 20s" — the precision that is useful at each scale.
String _took(Duration d) {
if (d.inMilliseconds < 1000) return '${d.inMilliseconds}ms';
if (d.inSeconds < 60) return '${d.inSeconds}s';
if (d.inMinutes < 60) return '${d.inMinutes}m ${d.inSeconds % 60}s';
return '${d.inHours}h ${d.inMinutes % 60}m';
}
class _Bubble extends StatelessWidget {
const _Bubble({required this.message, required this.running});
final ChatMessage message;
......@@ -809,6 +1001,9 @@ class _Bubble extends StatelessWidget {
);
}
// Queued is not working: it has not started, and saying "working…" for a
// run that is waiting its turn is exactly what made the queue invisible.
final waiting = running && message.isQueued;
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 14, right: 24),
......@@ -823,7 +1018,9 @@ class _Bubble extends StatelessWidget {
children: [
Row(
children: [
if (running)
if (waiting)
const Icon(Icons.schedule, size: 13, color: Ember.amber)
else if (running)
const SizedBox(
width: 12,
height: 12,
......@@ -861,10 +1058,29 @@ class _Bubble extends StatelessWidget {
style: const TextStyle(fontSize: 9.5, color: Ember.flame)),
),
],
if (running) ...[
if (waiting) ...[
const SizedBox(width: 6),
Flexible(
child: Text(
message.queuedReason!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 10.5, color: Ember.amber),
),
),
] else if (running) ...[
const SizedBox(width: 6),
const Text('working…',
style: TextStyle(fontSize: 10.5, color: Ember.textFaint)),
] else if (message.took != null) ...[
const SizedBox(width: 6),
Text(
_took(message.took!) +
(message.exitCode != null && message.exitCode != 0
? ' · exit ${message.exitCode}'
: ''),
style: const TextStyle(fontSize: 10.5, color: Ember.textFaint),
),
],
const Spacer(),
if (message.content.isNotEmpty)
......@@ -879,7 +1095,7 @@ class _Bubble extends StatelessWidget {
),
],
),
if (message.content.isNotEmpty || running) ...[
if (message.content.isNotEmpty || (running && !waiting)) ...[
const SizedBox(height: 10),
AgentOutput(
text: message.content,
......@@ -913,8 +1129,20 @@ class _Composer extends StatelessWidget {
required this.onQuick,
required this.onAddQuick,
required this.onRemoveQuick,
required this.queuedFollowUp,
required this.onCancelFollowUp,
required this.listening,
required this.onDictate,
required this.onDictateLanguage,
required this.onMentionFile,
});
final String? queuedFollowUp;
final VoidCallback onCancelFollowUp;
final bool listening;
final VoidCallback onDictate;
final VoidCallback onDictateLanguage;
final VoidCallback onMentionFile;
final FocusNode focusNode;
final List<String> quick;
final ValueChanged<String> onQuick;
......@@ -954,6 +1182,20 @@ class _Composer extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (queuedFollowUp != null)
Align(
alignment: Alignment.centerLeft,
child: Chip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.more_time, size: 14, color: Ember.amber),
label: Text(
'Sends next: ${queuedFollowUp!.length > 42 ? '${queuedFollowUp!.substring(0, 41)}…' : queuedFollowUp!}',
style: const TextStyle(fontSize: 11),
),
onDeleted: onCancelFollowUp,
deleteIcon: const Icon(Icons.close, size: 13),
),
),
if (pending.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
......@@ -986,8 +1228,29 @@ class _Composer extends StatelessWidget {
),
const Spacer(),
IconButton(
tooltip: 'Insert a file path',
icon: const Icon(Icons.alternate_email, size: 19),
color: Ember.textDim,
visualDensity: VisualDensity.compact,
onPressed: onMentionFile,
),
// Long-press picks the language: an Arabic sentence dictated into
// an English recogniser comes back as nonsense.
GestureDetector(
onLongPress: onDictateLanguage,
child: IconButton(
tooltip: listening ? 'Stop dictation' : 'Dictate — long-press for language',
icon: Icon(listening ? Icons.mic : Icons.mic_none, size: 20),
color: listening ? Ember.flame : Ember.textDim,
visualDensity: VisualDensity.compact,
onPressed: onDictate,
),
),
IconButton(
tooltip: 'Attach a file',
icon: const Icon(Icons.attach_file, size: 20),
color: Ember.textDim,
visualDensity: VisualDensity.compact,
onPressed: onAttach,
),
],
......@@ -1044,32 +1307,39 @@ class _Composer extends StatelessWidget {
),
),
const SizedBox(width: 8),
// While a run is live both buttons are there: stop it, or write
// the next instruction and let it queue behind this one.
if (running) ...[
SizedBox(
width: 44,
height: 46,
child: IconButton.filled(
tooltip: 'Stop this run',
onPressed: onStop,
style: IconButton.styleFrom(backgroundColor: Ember.bad),
icon: const Icon(Icons.stop, size: 20),
),
),
const SizedBox(width: 6),
],
SizedBox(
width: 46,
height: 46,
child: running
? IconButton.filled(
onPressed: onStop,
style: IconButton.styleFrom(backgroundColor: Ember.bad),
icon: const Icon(Icons.stop, size: 20),
)
: DecoratedBox(
decoration: const BoxDecoration(
gradient: Ember.fire,
shape: BoxShape.circle,
),
child: IconButton(
onPressed: sending ? null : onSend,
icon: sending
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white),
)
: const Icon(Icons.arrow_upward, size: 20, color: Colors.white),
),
),
child: DecoratedBox(
decoration: const BoxDecoration(gradient: Ember.fire, shape: BoxShape.circle),
child: IconButton(
tooltip: running ? 'Queue for when this run finishes' : 'Send',
onPressed: sending ? null : onSend,
icon: sending
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Icon(running ? Icons.more_time : Icons.arrow_upward,
size: 20, color: Colors.white),
),
),
),
],
),
......@@ -1148,6 +1418,120 @@ class _Selector extends StatelessWidget {
}
/// Picks a file to name in a prompt. Searching beats typing
/// "lib/features/chats/chat_screen.dart" on a phone keyboard, and a path typed
/// slightly wrong sends the agent looking in the wrong place.
class _FilePickSheet extends StatefulWidget {
const _FilePickSheet({required this.project, required this.api});
final String project;
final SonApi api;
@override
State<_FilePickSheet> createState() => _FilePickSheetState();
}
class _FilePickSheetState extends State<_FilePickSheet> {
final _q = TextEditingController();
Timer? _debounce;
List<FileEntry> _results = const [];
bool _busy = false;
@override
void initState() {
super.initState();
_search('');
}
@override
void dispose() {
_debounce?.cancel();
_q.dispose();
super.dispose();
}
void _onChanged(String v) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () => _search(v));
}
Future<void> _search(String v) async {
setState(() => _busy = true);
try {
// Under two characters there is nothing to match on, so show the root
// instead of every file in the repo.
final r = v.trim().length < 2
? (await widget.api.list(widget.project, '.')).entries
: await widget.api.search(widget.project, v.trim());
if (mounted) {
setState(() {
_results = r;
_busy = false;
});
}
} catch (_) {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: TextField(
controller: _q,
autofocus: true,
onChanged: _onChanged,
decoration: InputDecoration(
hintText: 'Find a file in ${widget.project}…',
prefixIcon: const Icon(Icons.search, size: 18),
suffixIcon: _busy
? const Padding(
padding: EdgeInsets.all(14),
child: SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2)),
)
: null,
),
),
),
const Divider(height: 1),
Expanded(
child: _results.isEmpty
? const Center(
child: Text('Nothing matches', style: TextStyle(color: Ember.textDim)))
: ListView.builder(
itemCount: _results.length,
itemBuilder: (_, i) {
final e = _results[i];
return ListTile(
dense: true,
leading: Icon(e.isDir ? Icons.folder : Icons.description_outlined,
size: 19, color: e.isDir ? Ember.flame : Ember.textDim),
title: Text(e.name, style: const TextStyle(fontSize: 13.5)),
subtitle: Text(e.path,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
onTap: () => Navigator.pop(context, e.path),
);
},
),
),
],
),
),
);
}
}
/// Jump-to-latest, floating over the transcript. Pulses while a run is live,
/// because that is when new output is arriving off-screen.
class _JumpButton extends StatelessWidget {
......
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../../core/api/models.dart';
import '../../core/api/son_api.dart';
import '../../core/prefs.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/empty_state.dart';
import '../../widgets/flame_mark.dart';
import 'chat_screen.dart';
import 'runs_sheet.dart';
class ChatsScreen extends ConsumerStatefulWidget {
const ChatsScreen({super.key});
......@@ -21,12 +26,102 @@ class _ChatsScreenState extends ConsumerState<ChatsScreen> {
String _query = '';
String _filter = 'all';
Set<String> _pinned = {};
/// Matches found *inside* conversations, which the local title filter cannot
/// see. Fetched only once the query is worth a round trip.
List<ChatHit> _hits = const [];
Timer? _debounce;
@override
void initState() {
super.initState();
Prefs.pinnedChats().then((p) {
if (mounted) setState(() => _pinned = p);
});
}
@override
void dispose() {
_debounce?.cancel();
_search.dispose();
super.dispose();
}
void _onQuery(String v) {
setState(() => _query = v);
_debounce?.cancel();
if (v.trim().length < 2) {
setState(() => _hits = const []);
return;
}
_debounce = Timer(const Duration(milliseconds: 400), () async {
try {
final hits = await ref.read(apiProvider).searchChats(v.trim());
if (mounted && _search.text.trim() == v.trim()) setState(() => _hits = hits);
} on ApiException {
// The local title filter still works; a failed deep search is not worth
// an error banner over the list.
}
});
}
Future<void> _togglePin(String id) async {
final next = await Prefs.togglePinnedChat(id);
if (mounted) setState(() => _pinned = next);
}
/// The conversations that mention the search text, with the line that matched.
void _showHits() {
showModalBottomSheet<void>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (sheet) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.75,
builder: (_, ctrl) => ListView.separated(
controller: ctrl,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _hits.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final h = _hits[i];
return ListTile(
title: Text(h.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(h.project, style: const TextStyle(fontSize: 11, color: Ember.flame)),
const SizedBox(height: 3),
for (final m in h.matches.take(2))
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${m.label}: ${m.text}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11.5, color: Ember.textDim, height: 1.3),
),
),
],
),
isThreeLine: true,
onTap: () {
Navigator.pop(sheet);
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => ChatScreen(chatId: h.id)));
},
);
},
),
),
);
}
List<Chat> _visible(List<Chat> all) {
final q = _query.trim().toLowerCase();
return all.where((c) {
......@@ -42,7 +137,15 @@ class _ChatsScreenState extends ConsumerState<ChatsScreen> {
return c.title.toLowerCase().contains(q) ||
c.project.toLowerCase().contains(q) ||
c.lastPrompt.toLowerCase().contains(q);
}).toList();
}).toList()
// Pinned first, newest next. The server orders by activity, which buries
// the one chat you keep coming back to.
..sort((a, b) {
final pa = _pinned.contains(a.id);
final pb = _pinned.contains(b.id);
if (pa != pb) return pa ? -1 : 1;
return b.updatedAt.compareTo(a.updatedAt);
});
}
Future<void> _newChat() async {
......@@ -75,6 +178,24 @@ class _ChatsScreenState extends ConsumerState<ChatsScreen> {
],
),
actions: [
// What the server is doing, with the queue visible. The count turns
// amber when something is waiting rather than working.
Consumer(
builder: (_, r, __) {
final snap = r.watch(runsProvider).valueOrNull;
final total = (snap?.running.length ?? 0) + (snap?.queued.length ?? 0);
return IconButton(
tooltip: 'What the server is doing',
onPressed: () => showRunsSheet(context),
icon: Badge(
isLabelVisible: total > 0,
backgroundColor: (snap?.queued.isNotEmpty ?? false) ? Ember.amber : Ember.flame,
label: Text('$total'),
child: const Icon(Icons.bolt_outlined),
),
);
},
),
IconButton(
tooltip: 'Refresh',
icon: const Icon(Icons.refresh),
......@@ -95,10 +216,10 @@ class _ChatsScreenState extends ConsumerState<ChatsScreen> {
padding: const EdgeInsets.fromLTRB(16, 4, 16, 10),
child: TextField(
controller: _search,
onChanged: (v) => setState(() => _query = v),
onChanged: _onQuery,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: 'Search chats…',
hintText: 'Search chats and messages…',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _query.isEmpty
? null
......@@ -106,7 +227,7 @@ class _ChatsScreenState extends ConsumerState<ChatsScreen> {
icon: const Icon(Icons.close, size: 18),
onPressed: () {
_search.clear();
setState(() => _query = '');
_onQuery('');
},
),
),
......@@ -145,6 +266,39 @@ class _ChatsScreenState extends ConsumerState<ChatsScreen> {
],
),
),
// Matches inside conversations — the answer you half remember is in
// a message, not in a title.
if (_hits.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: _showHits,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
decoration: BoxDecoration(
color: Ember.surfaceHi,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Ember.border),
),
child: Row(
children: [
const Icon(Icons.manage_search, size: 17, color: Ember.flame),
const SizedBox(width: 9),
Expanded(
child: Text(
'${_hits.length} conversation${_hits.length == 1 ? '' : 's'} mention “${_query.trim()}”',
style: const TextStyle(fontSize: 12.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.chevron_right, size: 18, color: Ember.textFaint),
],
),
),
),
),
const SizedBox(height: 8),
Expanded(
child: chats.when(
......@@ -177,7 +331,11 @@ class _ChatsScreenState extends ConsumerState<ChatsScreen> {
padding: const EdgeInsets.fromLTRB(16, 0, 16, 96),
itemCount: list.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, i) => _ChatTile(chat: list[i]),
itemBuilder: (_, i) => _ChatTile(
chat: list[i],
pinned: _pinned.contains(list[i].id),
onTogglePin: () => _togglePin(list[i].id),
),
),
);
},
......@@ -190,8 +348,10 @@ class _ChatsScreenState extends ConsumerState<ChatsScreen> {
}
class _ChatTile extends ConsumerWidget {
const _ChatTile({required this.chat});
const _ChatTile({required this.chat, required this.pinned, required this.onTogglePin});
final Chat chat;
final bool pinned;
final VoidCallback onTogglePin;
@override
Widget build(BuildContext context, WidgetRef ref) {
......@@ -234,6 +394,7 @@ class _ChatTile extends ConsumerWidget {
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ChatScreen(chatId: chat.id)),
),
onLongPress: onTogglePin,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
......@@ -243,6 +404,10 @@ class _ChatTile extends ConsumerWidget {
children: [
_StatusDot(status: chat.status),
const SizedBox(width: 9),
if (pinned) ...[
const Icon(Icons.push_pin, size: 13, color: Ember.flame),
const SizedBox(width: 6),
],
Expanded(
child: Text(
chat.title,
......
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/models.dart';
import '../../core/api/son_api.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
/// Everything the server is doing, and everything waiting to start.
///
/// This exists because a queued run and a working run looked identical from the
/// phone: two chats on one repo appeared to hang. Now the queue is visible,
/// says what it is waiting for, and can be cancelled from here.
void showRunsSheet(BuildContext context) {
showModalBottomSheet<void>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
maxChildSize: 0.92,
builder: (_, ctrl) => _RunsView(controller: ctrl),
),
);
}
class _RunsView extends ConsumerWidget {
const _RunsView({required this.controller});
final ScrollController controller;
@override
Widget build(BuildContext context, WidgetRef ref) {
final snap = ref.watch(runsProvider);
final data = snap.valueOrNull;
final running = data?.running ?? const <RunInfo>[];
final queued = data?.queued ?? const <RunInfo>[];
final limits = data?.limits;
return Column(
children: [
const SizedBox(height: 14),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.bolt, size: 18, color: Ember.flame),
const SizedBox(width: 8),
Text(
running.isEmpty && queued.isEmpty
? 'Nothing running'
: '${running.length} running · ${queued.length} waiting',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
],
),
if (limits != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Up to ${limits.maxConcurrent} at once, ${limits.maxPerProject} per repo · change in Settings',
style: const TextStyle(fontSize: 11, color: Ember.textFaint),
),
),
const SizedBox(height: 10),
const Divider(height: 1),
Expanded(
child: snap.hasError && data == null
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('${snap.error}',
textAlign: TextAlign.center,
style: const TextStyle(color: Ember.textDim)),
),
)
: (running.isEmpty && queued.isEmpty)
? const Center(
child: Padding(
padding: EdgeInsets.all(28),
child: Text(
'The box is idle.\nStart a chat and it appears here.',
textAlign: TextAlign.center,
style: TextStyle(color: Ember.textDim, height: 1.5),
),
),
)
: ListView(
controller: controller,
padding: const EdgeInsets.only(bottom: 24),
children: [
for (final r in running) _RunRow(run: r),
if (queued.isNotEmpty) ...[
const Padding(
padding: EdgeInsets.fromLTRB(16, 14, 16, 6),
child: Text('WAITING',
style: TextStyle(
fontSize: 10.5,
letterSpacing: 0.8,
color: Ember.amber,
fontWeight: FontWeight.w600)),
),
for (final r in queued) _RunRow(run: r),
],
],
),
),
],
);
}
}
class _RunRow extends ConsumerWidget {
const _RunRow({required this.run});
final RunInfo run;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListTile(
dense: true,
leading: run.queued
? const Icon(Icons.schedule, size: 18, color: Ember.amber)
: const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Ember.flame),
),
title: Text(
run.prompt.isEmpty ? '(no prompt)' : run.prompt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
run.queued
? (run.reason.isEmpty ? 'waiting' : run.reason)
: '${run.project} · ${_elapsed(run.elapsedSec)}',
style: TextStyle(fontSize: 11, color: run.queued ? Ember.amber : Ember.textFaint),
),
trailing: IconButton(
tooltip: run.queued ? 'Drop from the queue' : 'Stop this run',
icon: const Icon(Icons.stop_circle_outlined, size: 20, color: Ember.bad),
onPressed: () async {
try {
await ref.read(apiProvider).cancelRun(run.id);
ref.invalidate(runsProvider);
} on ApiException catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
},
),
);
}
}
String _elapsed(int seconds) {
if (seconds < 60) return '${seconds}s';
final m = seconds ~/ 60;
return m < 60 ? '${m}m ${seconds % 60}s' : '${m ~/ 60}h ${m % 60}m';
}
......@@ -11,6 +11,7 @@ import '../../core/api/son_api.dart';
import '../../core/prefs.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/diff_text.dart';
import 'file_actions.dart';
import 'mermaid_view.dart';
import 'pdf_flow.dart';
......@@ -23,12 +24,17 @@ class EditorScreen extends ConsumerStatefulWidget {
required this.project,
required this.path,
required this.name,
this.initialLine,
});
final String project;
final String path;
final String name;
/// Where a content-search match was: the file opens with the caret there
/// rather than at the top of a two-thousand-line file.
final int? initialLine;
@override
ConsumerState<EditorScreen> createState() => _EditorScreenState();
}
......@@ -73,6 +79,15 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
_loading = false;
if (!f.isBinary) _code.text = f.content;
});
final line = widget.initialLine;
if (line != null && !f.isBinary) {
try {
final at = (line - 1).clamp(0, _code.codeLines.length - 1);
_code.selection = CodeLineSelection.collapsed(index: at, offset: 0);
} catch (_) {
// Best effort: landing at the top still shows the file.
}
}
} catch (e) {
if (!mounted) return;
setState(() {
......@@ -128,12 +143,61 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
// The text on screen, unsaved edits included.
PdfFlow.forFile(context, api,
project: widget.project, path: widget.path, name: widget.name, source: _code.text);
case 'diff':
_showDiff();
case 'path':
Clipboard.setData(ClipboardData(text: widget.path));
toast(context, 'Copied ${widget.path}');
}
}
/// What changed in this file since the last commit — the question worth
/// asking right after an agent edits it.
Future<void> _showDiff() async {
try {
final diff = await ref.read(apiProvider).gitDiff(widget.project, widget.path);
if (!mounted) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.8,
builder: (_, ctrl) => Column(
children: [
const SizedBox(height: 6),
ListTile(
dense: true,
title: Text(widget.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(
diff.trim().isEmpty ? 'unchanged since the last commit' : diffStat(diff),
style: const TextStyle(fontSize: 11, color: Ember.textFaint),
),
),
const Divider(height: 1),
Expanded(
child: diff.trim().isEmpty
? const Center(
child: Text('No changes', style: TextStyle(color: Ember.textDim)))
: SingleChildScrollView(
controller: ctrl,
padding: const EdgeInsets.all(14),
child: DiffText(diff: diff),
),
),
],
),
),
);
} on ApiException catch (e) {
if (mounted) toast(context, e.message, bad: true);
}
}
Future<bool> _confirmLeave() async {
if (!_dirty) return true;
final ok = await showDialog<bool>(
......@@ -214,6 +278,7 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
const PopupMenuItem(value: 'share', child: Text('Share')),
if (f != null && !f.isBinary)
const PopupMenuItem(value: 'pdf', child: Text('Export as PDF')),
const PopupMenuItem(value: 'diff', child: Text('Diff vs HEAD')),
const PopupMenuItem(value: 'path', child: Text('Copy path')),
],
),
......
import 'dart:async';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
......@@ -36,6 +38,9 @@ class _FilesScreenState extends ConsumerState<FilesScreen> {
/// Paths picked by long-press. Non-empty is selection mode.
final Set<String> _selected = {};
/// Starred paths in this project, kept on the phone.
Set<String> _starred = {};
SonApi get _api => ref.read(apiProvider);
bool get _selecting => _selected.isNotEmpty;
bool get _canGoUp => (_listing?.parent ?? '').isNotEmpty;
......@@ -235,6 +240,10 @@ class _FilesScreenState extends ConsumerState<FilesScreen> {
/// Opens the folder last used in this project, falling back to the root when
/// it has since been deleted or renamed.
Future<void> _restore() async {
final project = _project;
Prefs.starredFiles(project).then((s) {
if (mounted && project == _project) setState(() => _starred = s.toSet());
});
final last = await Prefs.lastDir(_project);
if (last != null && last.isNotEmpty && last != '.' && await _list(last, quiet: true)) return;
await _list('.');
......@@ -286,15 +295,83 @@ class _FilesScreenState extends ConsumerState<FilesScreen> {
_zipContents(e);
return;
}
final page = isImageName(e.name)
? ImageViewerScreen(project: _project, path: e.path, name: e.name)
: EditorScreen(project: _project, path: e.path, name: e.name);
_openPath(e.path);
}
/// [line] comes from a content search: open the file where the match is.
void _openPath(String path, {int? line}) {
final name = path.split('/').last;
Prefs.pushRecentFile(_project, path);
final page = isImageName(name)
? ImageViewerScreen(project: _project, path: path, name: name)
: EditorScreen(project: _project, path: path, name: name, initialLine: line);
// Back from the editor may be back from a save: sizes and dates moved.
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => page))
.then((_) => _list(_path, quiet: true));
}
Future<void> _toggleStar(String path) async {
final next = await Prefs.toggleStarredFile(_project, path);
if (mounted) setState(() => _starred = next.toSet());
}
/// Starred files and the ones opened recently — the handful you keep coming
/// back to, without walking the tree to each of them.
Future<void> _showTracked() async {
final starred = await Prefs.starredFiles(_project);
final recent = await Prefs.recentFiles(_project);
if (!mounted) return;
if (starred.isEmpty && recent.isEmpty) {
toast(context, 'Nothing starred or opened yet in $_project');
return;
}
showModalBottomSheet<void>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (sheet) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
builder: (_, ctrl) => ListView(
controller: ctrl,
children: [
for (final (label, paths, icon) in [
('STARRED', starred, Icons.star),
('RECENT', recent, Icons.history),
])
if (paths.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
child: Text(label,
style: const TextStyle(
fontSize: 10.5,
letterSpacing: 0.8,
color: Ember.flame,
fontWeight: FontWeight.w600)),
),
for (final p in paths)
ListTile(
dense: true,
leading: Icon(icon, size: 18, color: Ember.textDim),
title: Text(p.split('/').last, style: const TextStyle(fontSize: 13.5)),
subtitle: Text(p,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
onTap: () {
Navigator.pop(sheet);
_openPath(p);
},
),
],
const SizedBox(height: 16),
],
),
),
);
}
// --- menus ----------------------------------------------------------------
void _sheet(List<_Act> acts, {Widget? header}) {
......@@ -333,6 +410,7 @@ class _FilesScreenState extends ConsumerState<FilesScreen> {
_Act(Icons.note_add_outlined, 'New file', () => _create(dir: false)),
_Act(Icons.create_new_folder_outlined, 'New folder', () => _create(dir: true)),
_Act(Icons.upload_file_outlined, 'Upload from phone', _upload),
_Act(Icons.star_outline, 'Starred & recent', _showTracked),
_Act(Icons.download_outlined, 'Download this folder (.zip)',
() => _downloadPaths([_path], isDir: true)),
_Act(Icons.picture_as_pdf_outlined, 'Export docs here as one PDF',
......@@ -353,6 +431,9 @@ class _FilesScreenState extends ConsumerState<FilesScreen> {
_Act(Icons.picture_as_pdf_outlined, e.isDir ? 'Export docs as one PDF' : 'Export as PDF',
() => e.isDir ? _folderPdf(e.path, e.name) : _filePdf(e)),
if (!zip) _Act(Icons.folder_zip_outlined, 'Compress to .zip', () => _compress([e.path])),
if (!e.isDir)
_Act(_starred.contains(e.path) ? Icons.star : Icons.star_border,
_starred.contains(e.path) ? 'Unstar' : 'Star', () => _toggleStar(e.path)),
_Act(Icons.check_box_outlined, 'Select', () => _toggle(e)),
_Act(Icons.link, 'Copy path', () {
Clipboard.setData(ClipboardData(text: e.path));
......@@ -612,40 +693,19 @@ class _FilesScreenState extends ConsumerState<FilesScreen> {
});
}
/// Search by name, or through the contents of the files themselves.
Future<void> _search() async {
final q = await _ask('Search files', 'Filename contains…');
if (q == null || q.isEmpty || !mounted) return;
try {
final results = await _api.search(_project, q, _path);
if (!mounted) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (sheet) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7,
builder: (_, ctrl) => results.isEmpty
? const Center(child: Text('Nothing matches', style: TextStyle(color: Ember.textDim)))
: ListView.builder(
controller: ctrl,
itemCount: results.length,
itemBuilder: (_, i) => ListTile(
leading: Icon(results[i].isDir ? Icons.folder : Icons.description_outlined,
size: 20, color: results[i].isDir ? Ember.flame : Ember.textDim),
title: Text(results[i].name, style: const TextStyle(fontSize: 14)),
subtitle: Text(results[i].path,
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
onTap: () {
Navigator.pop(sheet);
_openEntry(results[i]);
},
),
),
),
);
} on ApiException catch (e) {
if (mounted) toast(context, e.message, bad: true);
final picked = await showModalBottomSheet<({String path, bool isDir, int line})>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => _SearchSheet(project: _project, path: _path, api: _api),
);
if (picked == null || !mounted) return;
if (picked.isDir) {
_list(picked.path);
} else {
_openPath(picked.path, line: picked.line > 0 ? picked.line : null);
}
}
......@@ -903,3 +963,209 @@ class _EntryTile extends StatelessWidget {
);
}
}
/// Finding something in a repo from a phone: by file name, or by what is
/// written inside the files. The second is the one that matters when you
/// remember a string but not where it lives.
class _SearchSheet extends StatefulWidget {
const _SearchSheet({required this.project, required this.path, required this.api});
final String project;
final String path;
final SonApi api;
@override
State<_SearchSheet> createState() => _SearchSheetState();
}
class _SearchSheetState extends State<_SearchSheet> {
final _q = TextEditingController();
Timer? _debounce;
bool _contents = false;
bool _busy = false;
Object? _error;
List<FileEntry> _names = const [];
List<GrepHit> _hits = const [];
@override
void dispose() {
_debounce?.cancel();
_q.dispose();
super.dispose();
}
void _onChanged(String v) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 350), () => _run(v));
}
Future<void> _run(String v) async {
final q = v.trim();
if (q.length < 2) {
setState(() {
_names = const [];
_hits = const [];
_error = null;
});
return;
}
setState(() {
_busy = true;
_error = null;
});
try {
if (_contents) {
final r = await widget.api.grep(widget.project, q, path: widget.path);
if (mounted) setState(() => _hits = r);
} else {
final r = await widget.api.search(widget.project, q, widget.path);
if (mounted) setState(() => _names = r);
}
} on ApiException catch (e) {
if (mounted) setState(() => _error = e.message);
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final count = _contents ? _hits.length : _names.length;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: TextField(
controller: _q,
autofocus: true,
onChanged: _onChanged,
decoration: InputDecoration(
hintText: _contents ? 'Text inside files…' : 'File name contains…',
prefixIcon: const Icon(Icons.search, size: 18),
suffixIcon: _busy
? const Padding(
padding: EdgeInsets.all(14),
child: SizedBox(
width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
)
: null,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(value: false, label: Text('Names'), icon: Icon(Icons.badge_outlined, size: 15)),
ButtonSegment(value: true, label: Text('Contents'), icon: Icon(Icons.manage_search, size: 15)),
],
selected: {_contents},
showSelectedIcon: false,
style: ButtonStyle(
visualDensity: VisualDensity.compact,
textStyle: WidgetStateProperty.all(const TextStyle(fontSize: 12)),
),
onSelectionChanged: (s) {
setState(() => _contents = s.first);
_run(_q.text);
},
),
const Spacer(),
if (count > 0)
Text('$count', style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
],
),
),
const SizedBox(height: 6),
const Divider(height: 1),
Expanded(child: _results()),
],
),
),
);
}
Widget _results() {
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('$_error',
textAlign: TextAlign.center, style: const TextStyle(color: Ember.bad)),
),
);
}
if (_q.text.trim().length < 2) {
return Center(
child: Padding(
padding: const EdgeInsets.all(28),
child: Text(
_contents
? 'Searches inside the files under ${widget.path == '.' ? widget.project : widget.path}.'
: 'Searches file names under ${widget.path == '.' ? widget.project : widget.path}.',
textAlign: TextAlign.center,
style: const TextStyle(color: Ember.textDim, height: 1.5),
),
),
);
}
if (_contents) {
if (_hits.isEmpty && !_busy) {
return const Center(child: Text('No matches', style: TextStyle(color: Ember.textDim)));
}
return ListView.separated(
itemCount: _hits.length,
separatorBuilder: (_, __) => const Divider(height: 1, indent: 16),
itemBuilder: (_, i) {
final h = _hits[i];
return ListTile(
dense: true,
title: Text('${h.name}:${h.line}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(h.text.trim(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11.5, fontFamilyFallback: Ember.mono, color: Ember.text)),
Text(h.path,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 10.5, color: Ember.textFaint)),
],
),
isThreeLine: true,
onTap: () => Navigator.pop(context, (path: h.path, isDir: false, line: h.line)),
);
},
);
}
if (_names.isEmpty && !_busy) {
return const Center(child: Text('Nothing matches', style: TextStyle(color: Ember.textDim)));
}
return ListView.builder(
itemCount: _names.length,
itemBuilder: (_, i) {
final e = _names[i];
return ListTile(
dense: true,
leading: Icon(e.isDir ? Icons.folder : Icons.description_outlined,
size: 19, color: e.isDir ? Ember.flame : Ember.textDim),
title: Text(e.name, style: const TextStyle(fontSize: 13.5)),
subtitle: Text(e.path,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
onTap: () => Navigator.pop(context, (path: e.path, isDir: e.isDir, line: 0)),
);
},
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../../core/api/models.dart';
import '../../core/api/son_api.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/diff_text.dart';
import '../../widgets/empty_state.dart';
class GitScreen extends ConsumerWidget {
......@@ -155,24 +157,250 @@ class _RepoCardState extends ConsumerState<_RepoCard> {
Future<void> _commit() async {
final ctl = TextEditingController();
var writing = false;
final msg = await showDialog<String>(
context: context,
builder: (c) => StatefulBuilder(
builder: (c, setDialog) => AlertDialog(
title: Text('Commit ${widget.repo.name}'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: ctl,
autofocus: true,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(hintText: 'Commit message'),
),
const SizedBox(height: 4),
// Reads the diff on the server and writes the message from what
// actually changed — the part nobody wants to type on a phone.
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
style: TextButton.styleFrom(foregroundColor: Ember.flame),
onPressed: writing
? null
: () async {
setDialog(() => writing = true);
try {
final m = await _api.gitCommitMessage(widget.repo.name);
ctl.value = TextEditingValue(
text: m, selection: TextSelection.collapsed(offset: m.length));
} on ApiException catch (e) {
if (c.mounted) {
ScaffoldMessenger.of(c)
.showSnackBar(SnackBar(content: Text(e.message)));
}
} finally {
if (c.mounted) setDialog(() => writing = false);
}
},
icon: writing
? const SizedBox(
width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.auto_awesome, size: 16),
label: Text(writing ? 'Reading the diff…' : 'Write it from the diff'),
),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(c), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(c, ctl.text.trim()), child: const Text('Commit')),
],
),
),
);
if (msg == null || msg.isEmpty) return;
await _run(() => _api.gitCommit(widget.repo.name, msg), 'Committed');
}
/// Branches: which one this checkout is on, and switching without a laptop.
Future<void> _branches() async {
try {
final list = await _api.gitBranches(widget.repo.name);
if (!mounted) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.call_split, color: Ember.flame),
title: Text('${widget.repo.name} · branches',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
trailing: TextButton.icon(
onPressed: () {
Navigator.pop(sheet);
_newBranch();
},
icon: const Icon(Icons.add, size: 16),
label: const Text('New'),
),
),
const Divider(height: 1),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: list.length,
itemBuilder: (_, i) {
final b = list[i];
final when = b.at == null ? '' : timeago.format(b.at!);
return ListTile(
dense: true,
leading: Icon(
b.current ? Icons.radio_button_checked : Icons.radio_button_unchecked,
size: 18,
color: b.current ? Ember.flame : Ember.textFaint,
),
title: Text(b.name,
style: TextStyle(
fontSize: 13.5,
fontWeight: b.current ? FontWeight.w600 : FontWeight.w400)),
subtitle: Text(
[if (b.subject.isNotEmpty) b.subject, if (when.isNotEmpty) when]
.join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11, color: Ember.textFaint),
),
onTap: b.current
? null
: () {
Navigator.pop(sheet);
_switchTo(b.name);
},
);
},
),
),
const SizedBox(height: 8),
],
),
),
);
} on ApiException catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
Future<void> _switchTo(String branch, {bool create = false}) => _run(
() => _api.gitCheckout(widget.repo.name, branch, create: create),
create ? 'Created $branch' : 'Switched to $branch',
);
Future<void> _newBranch() async {
final ctl = TextEditingController();
final name = await showDialog<String>(
context: context,
builder: (c) => AlertDialog(
title: Text('Commit ${widget.repo.name}'),
title: const Text('New branch'),
content: TextField(
controller: ctl,
autofocus: true,
maxLines: 3,
decoration: const InputDecoration(hintText: 'Commit message'),
decoration: const InputDecoration(hintText: 'feature/what-it-does'),
onSubmitted: (v) => Navigator.pop(c, v.trim()),
),
actions: [
TextButton(onPressed: () => Navigator.pop(c), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(c, ctl.text.trim()), child: const Text('Commit')),
TextButton(
onPressed: () => Navigator.pop(c, ctl.text.trim()), child: const Text('Create')),
],
),
);
if (msg == null || msg.isEmpty) return;
await _run(() => _api.gitCommit(widget.repo.name, msg), 'Committed');
if (name == null || name.isEmpty) return;
await _switchTo(name, create: true);
}
/// Recent commits, each openable as its own patch.
Future<void> _history() async {
try {
final commits = await _api.gitLog(widget.repo.name);
if (!mounted) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (sheet) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.75,
builder: (_, ctrl) => commits.isEmpty
? const Center(child: Text('No commits yet', style: TextStyle(color: Ember.textDim)))
: ListView.separated(
controller: ctrl,
itemCount: commits.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final c = commits[i];
return ListTile(
dense: true,
title: Text(c.message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13)),
subtitle: Text(
'${c.sha} · ${c.author} · ${timeago.format(c.date)}',
style: const TextStyle(fontSize: 11, color: Ember.textFaint),
),
onTap: () {
Navigator.pop(sheet);
_showCommit(c.sha, c.message);
},
);
},
),
),
);
} on ApiException catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
Future<void> _showCommit(String sha, String subject) async {
try {
final diff = await _api.gitShow(widget.repo.name, sha);
if (!mounted) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.85,
builder: (_, ctrl) => Column(
children: [
const SizedBox(height: 6),
ListTile(
dense: true,
title: Text(subject,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text('$sha · ${_stat(diff)}',
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
),
const Divider(height: 1),
Expanded(
child: SingleChildScrollView(
controller: ctrl,
padding: const EdgeInsets.all(14),
child: DiffText(diff: diff),
),
),
],
),
),
);
} on ApiException catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
/// Porcelain quotes paths with spaces and writes renames as "old -> new";
......@@ -265,26 +493,7 @@ class _RepoCardState extends ConsumerState<_RepoCard> {
: SingleChildScrollView(
controller: ctrl,
padding: const EdgeInsets.all(14),
child: SelectableText.rich(
TextSpan(children: [
for (final line in diff.split('\n'))
TextSpan(
text: '$line\n',
style: TextStyle(
fontSize: 11.5,
height: 1.4,
fontFamilyFallback: const [...Ember.mono],
color: line.startsWith('+') && !line.startsWith('+++')
? Ember.ok
: line.startsWith('-') && !line.startsWith('---')
? Ember.bad
: line.startsWith('@@')
? Ember.info
: Ember.textDim,
),
),
]),
),
child: DiffText(diff: diff),
),
),
],
......@@ -326,7 +535,13 @@ class _RepoCardState extends ConsumerState<_RepoCard> {
spacing: 6,
runSpacing: 6,
children: [
_tag(Icons.call_split, r.branch),
// The branch is a button: switching one without opening a
// laptop is half the reason this tab exists.
InkWell(
borderRadius: BorderRadius.circular(999),
onTap: _branches,
child: _tag(Icons.call_split, r.branch),
),
if (r.dirty) _tag(Icons.edit_outlined, '${r.changed} changed', Ember.amber),
if (r.ahead > 0) _tag(Icons.arrow_upward, '${r.ahead} ahead', Ember.info),
if (r.behind > 0) _tag(Icons.arrow_downward, '${r.behind} behind', Ember.info),
......@@ -342,6 +557,7 @@ class _RepoCardState extends ConsumerState<_RepoCard> {
Row(
children: [
_act(Icons.difference_outlined, 'Diff', () => _showDiff()),
_act(Icons.history, 'Log', _history),
_act(Icons.download_outlined, 'Pull',
() => _run(() => _api.gitPull(r.name), 'Pulled')),
_act(Icons.check_circle_outline, 'Commit', r.dirty ? _commit : null),
......
......@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/models.dart';
import '../../core/api/son_api.dart';
import '../../core/lock.dart';
import '../../core/prefs.dart';
import '../../core/providers.dart';
......@@ -42,6 +43,20 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
});
}
Future<void> _setLimits({int? maxConcurrent, int? maxPerProject}) async {
try {
await ref
.read(apiProvider)
.setLimits(maxConcurrent: maxConcurrent, maxPerProject: maxPerProject);
ref.invalidate(limitsProvider);
ref.invalidate(runsProvider);
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
}
@override
Widget build(BuildContext context) {
final session = ref.watch(sessionProvider);
......@@ -105,6 +120,78 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
),
const SizedBox(height: 20),
const _SectionLabel('Server'),
Card(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: ref.watch(systemProvider).when(
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(10),
child: SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
),
),
error: (e, _) => Text('Unavailable — $e',
style: const TextStyle(color: Ember.textDim, fontSize: 12)),
data: (s) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Meter(label: 'CPU load', pct: (s.loadPerCore * 100).round(),
detail: '${s.loadPerCore.toStringAsFixed(2)} per core · ${s.cpus} CPUs'),
const SizedBox(height: 12),
_Meter(label: 'Memory', pct: s.memUsedPct, detail: _gb(s.memTotalBytes)),
const SizedBox(height: 12),
_Meter(label: 'Disk', pct: s.diskUsedPct, detail: '${_gb(s.diskFreeBytes)} free'),
const SizedBox(height: 10),
Text(
'${s.running} running · ${s.queued} waiting · up ${_uptime(s.uptimeSec)}',
style: const TextStyle(fontSize: 11, color: Ember.textFaint),
),
],
),
),
),
),
const SizedBox(height: 20),
const _SectionLabel('How much runs at once'),
Card(
child: ref.watch(limitsProvider).when(
loading: () => const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: SizedBox(
width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)),
),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(16),
child: Text('Unavailable — $e',
style: const TextStyle(color: Ember.textDim, fontSize: 12)),
),
data: (l) => Column(
children: [
_LimitSlider(
label: 'Runs at once',
value: l.maxConcurrent,
max: l.ceilingConcurrent,
note: 'Across every repo. More means more of the Claude quota at once.',
onChanged: (v) => _setLimits(maxConcurrent: v),
),
const Divider(height: 1),
_LimitSlider(
label: 'Runs per repo',
value: l.maxPerProject,
max: l.ceilingPerProject,
note: 'Two chats on one repo work at the same time. They share one '
'checkout, so they can still tread on each other.',
onChanged: (v) => _setLimits(maxPerProject: v),
),
],
),
),
),
const SizedBox(height: 20),
const _SectionLabel('Display'),
Card(
child: Column(
......@@ -324,3 +411,111 @@ class _UsageBar extends StatelessWidget {
return 'in ${d.inMinutes}m';
}
}
String _gb(int bytes) => '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
String _uptime(int seconds) {
final d = seconds ~/ 86400;
final h = (seconds % 86400) ~/ 3600;
return d > 0 ? '${d}d ${h}h' : '${h}h';
}
/// One bar of "how much of this is in use". Colour tracks headroom, not brand.
class _Meter extends StatelessWidget {
const _Meter({required this.label, required this.pct, required this.detail});
final String label;
final int pct;
final String detail;
@override
Widget build(BuildContext context) {
final v = pct.clamp(0, 100);
final color = v >= 90 ? Ember.bad : (v >= 70 ? Ember.amber : Ember.ok);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(label, style: const TextStyle(fontSize: 13)),
const SizedBox(width: 8),
Text(detail, style: const TextStyle(fontSize: 10.5, color: Ember.textFaint)),
const Spacer(),
Text('$v%',
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700, color: color)),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: v / 100,
minHeight: 6,
backgroundColor: Ember.surfaceHi,
valueColor: AlwaysStoppedAnimation(color),
),
),
],
);
}
}
/// A whole-number limit. Committed on release, not on every pixel of the drag:
/// each change is a server call.
class _LimitSlider extends StatefulWidget {
const _LimitSlider({
required this.label,
required this.value,
required this.max,
required this.note,
required this.onChanged,
});
final String label;
final int value;
final int max;
final String note;
final ValueChanged<int> onChanged;
@override
State<_LimitSlider> createState() => _LimitSliderState();
}
class _LimitSliderState extends State<_LimitSlider> {
double? _dragging;
@override
Widget build(BuildContext context) {
final shown = (_dragging ?? widget.value.toDouble()).clamp(1.0, widget.max.toDouble()).toDouble();
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(widget.label, style: const TextStyle(fontSize: 14)),
const Spacer(),
Text('${shown.round()}',
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w700, color: Ember.flame)),
],
),
Slider(
value: shown,
min: 1,
max: widget.max.toDouble(),
divisions: widget.max - 1,
activeColor: Ember.flame,
label: '${shown.round()}',
onChanged: (v) => setState(() => _dragging = v),
onChangeEnd: (v) {
setState(() => _dragging = null);
widget.onChanged(v.round());
},
),
Text(widget.note, style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
],
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/notifications.dart';
import '../../core/theme.dart';
import '../caprover/caprover_screen.dart';
import '../chats/chat_screen.dart';
import '../chats/chats_screen.dart';
import '../files/files_screen.dart';
import '../git/git_screen.dart';
......@@ -22,6 +24,28 @@ class HomeShell extends ConsumerStatefulWidget {
class _HomeShellState extends ConsumerState<HomeShell> {
int _index = 1; // open on Chats: it is what the app is for
@override
void initState() {
super.initState();
// Tapping "your run finished" should land in that conversation, not merely
// open the app on whatever tab was last used.
Notifications.onOpenChat = _openChat;
// A tap that launched the app from cold happened before this existed.
WidgetsBinding.instance.addPostFrameCallback((_) => Notifications.flushPending());
}
@override
void dispose() {
if (Notifications.onOpenChat == _openChat) Notifications.onOpenChat = null;
super.dispose();
}
void _openChat(String chatId) {
if (!mounted || chatId.isEmpty) return;
setState(() => _index = 1);
Navigator.of(context).push(MaterialPageRoute(builder: (_) => ChatScreen(chatId: chatId)));
}
static const _tabs = <Widget>[
VsCodeScreen(),
ChatsScreen(),
......
import 'package:flutter/material.dart';
import '../core/theme.dart';
/// A patch, coloured the way every git tool colours one: additions green,
/// removals red, hunk headers blue. Selectable, because the reason to look at a
/// diff on a phone is usually to copy a line out of it.
class DiffText extends StatelessWidget {
const DiffText({super.key, required this.diff});
final String diff;
@override
Widget build(BuildContext context) => SelectableText.rich(
TextSpan(children: [
for (final line in diff.split('\n'))
TextSpan(
text: '$line\n',
style: TextStyle(
fontSize: 11.5,
height: 1.4,
fontFamilyFallback: const [...Ember.mono],
color: line.startsWith('+') && !line.startsWith('+++')
? Ember.ok
: line.startsWith('-') && !line.startsWith('---')
? Ember.bad
: line.startsWith('@@')
? Ember.info
: Ember.textDim,
),
),
]),
);
}
/// "+12 −3" for a patch — the shape of a change before reading it.
String diffStat(String diff) {
var add = 0;
var del = 0;
for (final l in diff.split('\n')) {
if (l.startsWith('+') && !l.startsWith('+++')) {
add++;
} else if (l.startsWith('-') && !l.startsWith('---')) {
del++;
}
}
return '+$add −$del';
}
......@@ -949,6 +949,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.2"
speech_to_text:
dependency: "direct main"
description:
name: speech_to_text
sha256: f2fabd017b5cb07f4e06d496b2d4b957e39534bc848f92b1e6acac80a10f4967
url: "https://pub.dev"
source: hosted
version: "7.5.0"
speech_to_text_platform_interface:
dependency: transitive
description:
name: speech_to_text_platform_interface
sha256: "7e447356be3074c471223c9d179ef3f822475350b67ca923ec71ab3bf2ede214"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
speech_to_text_windows:
dependency: transitive
description:
name: speech_to_text_windows
sha256: "2d1d10565b23262386b453b33656299608dc7a66784453735d6c1318f13f44d7"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
stack_trace:
dependency: transitive
description:
......
name: son_of_anton
description: Drive Claude Code against your GitLab repos from your phone.
publish_to: 'none'
version: 1.4.0+14
version: 1.5.0+15
environment:
sdk: ^3.8.0
......@@ -57,6 +57,7 @@ dependencies:
collection: ^1.18.0
printing: ^5.15.0
wakelock_plus: ^1.5.2
speech_to_text: ^7.5.0
dev_dependencies:
flutter_test:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment