Commit 0e8a19fa authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: Son of Anton — native client for driving Claude against GitLab repos

Ground-up replacement for the DevPilot APK whose source was lost in the Sep 1
wipe. That app could only be maintained by guessing at an opaque binary: two
days went into inferring its wire contract from `strings` on libapp.so, and the
last bug (every text file rendering as "Binary file") was never solvable from
the server side at all. Owning the source ends that entire class of problem.

Five tabs, each backed by a real endpoint:
  VS Code   code-server in a webview, host derived from the API host
  Chats     multi-turn conversations, live SSE, per-message model and effort,
            attachments from the phone, local notification when a run lands
  Files     project-scoped browser, re_editor with syntax highlighting,
            markdown rendered with mermaid diagrams, create/rename/delete/upload
  Git       per-repo status, coloured diff, pull/commit/push
  Settings  Claude usage against both rolling windows, connection, sign out

Decisions worth recording:

- models.dart parses defensively on purpose. Every fromJson is total and a
  wrong-typed field degrades to a default. The old client did the opposite and
  one bad field made *every* file unopenable, because a throw inside fromJson
  loses the whole object rather than one value.
- Binary is believed only when the server explicitly says so. Treating "unknown"
  as binary is precisely the bug that made the previous app useless.
- SSE is read off a raw HttpClient, not Dio: it is a line protocol held open
  indefinitely and Dio is built around a body that ends. The stream resumes with
  ?from=N so backgrounding the phone does not lose output.
- Mermaid renders in a webview because no Dart package draws it; the source is
  passed as a JSON literal so quotes and newlines cannot break out, and it falls
  back to showing the source offline instead of an empty box.
- Tabs live in an IndexedStack so scroll position, open directory and in-flight
  streams survive switching away — the app exists to leave work running.
- No FCM. The server has nothing to push with, and the only event worth an
  interruption is one the app already observes.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parents
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Widget Preview related
.widget_preview/
# Release signing material never belongs in the repo
android/key.properties
*.keystore
*.jks
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "4cf24164269a5ebf0c16a028a00727d0e77bbb05"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
- platform: android
create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
# son_of_anton
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.alarcade.son_of_anton"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.alarcade.son_of_anton"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Talks to the DevPilot API and code-server over the network. -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- Tells you a run finished while the app was closed. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<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"/>
<application
android:label="Son of Anton"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
package com.alarcade.son_of_anton
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
// Wire models.
//
// Every fromJson here is deliberately total: it never throws, and a field of an
// unexpected type degrades to a default instead of aborting the parse. That is
// not defensive habit, it is the lesson from the previous client — one
// wrong-typed field made every file in the app render as "binary", because a
// throw inside fromJson kills the whole object, not just that field.
//
// Helpers first; every model uses them rather than casting directly.
String _s(dynamic v, [String d = '']) => v == null ? d : v.toString();
int _i(dynamic v, [int d = 0]) => switch (v) {
int n => n,
double n => n.toInt(),
String s => int.tryParse(s) ?? d,
_ => d,
};
int? _iOrNull(dynamic v) => v == null ? null : _i(v, 0);
bool _b(dynamic v, [bool d = false]) => switch (v) {
bool x => x,
int n => n != 0,
String s => s == 'true' || s == '1' || s == 'yes',
_ => d,
};
DateTime _dt(dynamic v) {
if (v is int) return DateTime.fromMillisecondsSinceEpoch(v).toLocal();
final s = _s(v);
return DateTime.tryParse(s)?.toLocal() ?? DateTime.fromMillisecondsSinceEpoch(0);
}
List<T> _list<T>(dynamic v, T Function(Map<String, dynamic>) f) {
if (v is! List) return const [];
final out = <T>[];
for (final e in v) {
if (e is Map) out.add(f(e.cast<String, dynamic>()));
}
return out;
}
List<String> _strings(dynamic v) =>
v is List ? v.map((e) => _s(e)).where((s) => s.isNotEmpty).toList() : const [];
/// Reads the first present key. The server sends aliases for most fields; this
/// is the client half of that tolerance.
dynamic _any(Map<String, dynamic> j, List<String> keys) {
for (final k in keys) {
final v = j[k];
if (v != null) return v;
}
return null;
}
// --- projects ---------------------------------------------------------------
class Project {
const Project({required this.name, required this.path});
final String name;
final String path;
factory Project.fromJson(Map<String, dynamic> j) =>
Project(name: _s(j['name']), path: _s(j['path']));
}
// --- chats ------------------------------------------------------------------
enum RunStatus { idle, running, completed, failed, cancelled }
RunStatus runStatus(dynamic v) => switch (_s(v)) {
'running' || 'queued' || 'finishing' => RunStatus.running,
'completed' => RunStatus.completed,
'failed' => RunStatus.failed,
'cancelled' => RunStatus.cancelled,
_ => RunStatus.idle,
};
class Attachment {
const Attachment({required this.name, required this.size, required this.path});
final String name;
final int size;
final String path;
factory Attachment.fromJson(Map<String, dynamic> j) => Attachment(
name: _s(j['name']),
size: _i(j['size']),
path: _s(j['path']),
);
}
class ChatMessage {
const ChatMessage({
required this.id,
required this.taskId,
required this.role,
required this.content,
required this.status,
required this.createdAt,
this.attachments = const [],
this.model,
this.effort,
this.exitCode,
});
final String id;
final String taskId;
final String role; // 'user' | 'assistant'
final String content;
final RunStatus status;
final DateTime createdAt;
final List<String> attachments;
final String? model;
final String? effort;
final int? exitCode;
bool get isUser => role == 'user';
factory ChatMessage.fromJson(Map<String, dynamic> j) => ChatMessage(
id: _s(j['id']),
taskId: _s(j['taskId']),
role: _s(j['role'], 'assistant'),
content: _s(_any(j, ['content', 'text', 'output'])),
status: runStatus(j['status']),
createdAt: _dt(j['createdAt']),
attachments: _strings(j['attachments']),
model: j['model'] == null ? null : _s(j['model']),
effort: j['effort'] == null ? null : _s(j['effort']),
exitCode: _iOrNull(j['exitCode']),
);
ChatMessage copyWith({String? content, RunStatus? status}) => ChatMessage(
id: id,
taskId: taskId,
role: role,
content: content ?? this.content,
status: status ?? this.status,
createdAt: createdAt,
attachments: attachments,
model: model,
effort: effort,
exitCode: exitCode,
);
}
class Chat {
const Chat({
required this.id,
required this.project,
required this.title,
required this.status,
required this.messageCount,
required this.updatedAt,
this.sessionId,
this.model,
this.effort,
this.lastPrompt = '',
this.archived = false,
this.messages = const [],
this.attachments = const [],
});
final String id;
final String project;
final String title;
final RunStatus status;
final int messageCount;
final DateTime updatedAt;
final String? sessionId;
final String? model;
final String? effort;
final String lastPrompt;
final bool archived;
final List<ChatMessage> messages;
final List<Attachment> attachments;
bool get isRunning => status == RunStatus.running;
factory Chat.fromJson(Map<String, dynamic> j) => Chat(
id: _s(j['id']),
project: _s(j['project']),
title: _s(j['title'], 'New chat'),
status: runStatus(j['status']),
messageCount: _i(j['messageCount']),
updatedAt: _dt(_any(j, ['updatedAt', 'createdAt'])),
sessionId: j['sessionId'] == null ? null : _s(j['sessionId']),
model: j['model'] == null ? null : _s(j['model']),
effort: j['effort'] == null ? null : _s(j['effort']),
lastPrompt: _s(j['lastPrompt']),
archived: _b(j['archived']),
messages: _list(j['messages'], ChatMessage.fromJson),
attachments: _list(j['attachments'], Attachment.fromJson),
);
}
// --- model / effort vocabulary ---------------------------------------------
class ModelOption {
const ModelOption({required this.id, required this.label, this.hint = ''});
final String id;
final String label;
final String hint;
factory ModelOption.fromJson(Map<String, dynamic> j) => ModelOption(
id: _s(j['id']),
label: _s(j['label'], _s(j['id'])),
hint: _s(j['hint']),
);
}
class Vocabulary {
const Vocabulary({required this.models, required this.efforts});
final List<ModelOption> models;
final List<ModelOption> efforts;
/// Used until /api/models answers, and if it ever fails — the picker must
/// never be empty, because an empty picker means the user cannot send at all.
static const fallback = Vocabulary(
models: [
ModelOption(id: '', label: 'Auto', hint: 'account default'),
ModelOption(id: 'fable', label: 'Fable', hint: 'fastest'),
ModelOption(id: 'sonnet', label: 'Sonnet', hint: 'balanced'),
ModelOption(id: 'opus', label: 'Opus', hint: 'most capable'),
],
efforts: [
ModelOption(id: '', label: 'Auto'),
ModelOption(id: 'low', label: 'Quick'),
ModelOption(id: 'medium', label: 'Normal'),
ModelOption(id: 'high', label: 'Hard'),
ModelOption(id: 'xhigh', label: 'Max'),
ModelOption(id: 'max', label: 'Ultra'),
],
);
factory Vocabulary.fromJson(Map<String, dynamic> j) {
final m = _list(j['models'], ModelOption.fromJson);
final e = _list(j['efforts'], ModelOption.fromJson);
return Vocabulary(
models: m.isEmpty ? fallback.models : m,
efforts: e.isEmpty ? fallback.efforts : e,
);
}
}
// --- files ------------------------------------------------------------------
class FileEntry {
const FileEntry({
required this.name,
required this.path,
required this.isDir,
required this.size,
this.language = '',
this.modified,
});
final String name;
final String path;
final bool isDir;
final int size;
final String language;
final DateTime? modified;
factory FileEntry.fromJson(Map<String, dynamic> j) => FileEntry(
name: _s(j['name']),
path: _s(j['path']),
isDir: _b(_any(j, ['isDir', 'isDirectory'])) || _s(j['type']) == 'dir',
size: _i(j['size']),
language: _s(_any(j, ['language', 'lang'])),
modified: j['modified'] == null ? null : _dt(j['modified']),
);
}
class DirListing {
const DirListing({required this.path, required this.parent, required this.entries});
final String path;
final String? parent;
final List<FileEntry> entries;
factory DirListing.fromJson(Map<String, dynamic> j) => DirListing(
path: _s(j['path']),
parent: j['parent'] == null ? null : _s(j['parent']),
entries: _list(_any(j, ['entries', 'items', 'files']), FileEntry.fromJson),
);
}
class FileContent {
const FileContent({
required this.path,
required this.name,
required this.content,
required this.isBinary,
required this.size,
this.language = '',
this.mimeType = '',
});
final String path;
final String name;
final String content;
final bool isBinary;
final int size;
final String language;
final String mimeType;
bool get isMarkdown =>
language == 'markdown' || mimeType == 'text/markdown' || name.toLowerCase().endsWith('.md');
factory FileContent.fromJson(Map<String, dynamic> j) {
// Binary is decided by the server's explicit flag only. Absence means text:
// treating "unknown" as binary is exactly the bug that made every file in
// the old client unopenable.
final binary = _b(_any(j, ['binary', 'isBinary']), false);
return FileContent(
path: _s(j['path']),
name: _s(j['name']),
content: _s(_any(j, ['content', 'text'])),
isBinary: binary,
size: _i(j['size']),
language: _s(_any(j, ['language', 'lang'])),
mimeType: _s(_any(j, ['mimeType', 'contentType', 'mime'])),
);
}
}
// --- git --------------------------------------------------------------------
class RepoStatus {
const RepoStatus({
required this.name,
required this.branch,
required this.dirty,
required this.cloned,
this.ahead = 0,
this.behind = 0,
this.changed = 0,
this.modified = const [],
this.untracked = const [],
});
final String name;
final String branch;
final bool dirty;
final bool cloned;
final int ahead;
final int behind;
final int changed;
final List<String> modified;
final List<String> untracked;
factory RepoStatus.fromJson(Map<String, dynamic> j) {
final mod = _strings(j['modified']);
final unt = _strings(j['untracked']);
return RepoStatus(
name: _s(_any(j, ['name', 'repo'])),
branch: _s(_any(j, ['branch', 'head']), '—'),
dirty: _b(_any(j, ['dirty', 'hasChanges'])),
cloned: _b(j['cloned'], true),
ahead: _i(j['ahead']),
behind: _i(j['behind']),
changed: _i(_any(j, ['changes', 'count']), mod.length + unt.length),
modified: mod,
untracked: unt,
);
}
}
class Commit {
const Commit({required this.sha, required this.message, required this.author, required this.date});
final String sha;
final String message;
final String author;
final DateTime date;
factory Commit.fromJson(Map<String, dynamic> j) => Commit(
sha: _s(_any(j, ['shortSha', 'sha'])),
message: _s(_any(j, ['message', 'subject'])),
author: _s(j['author']),
date: _dt(j['date']),
);
}
// --- usage ------------------------------------------------------------------
class UsageWindow {
const UsageWindow({required this.percent, required this.resetsAt});
final int percent;
final DateTime? resetsAt;
factory UsageWindow.fromJson(Map<String, dynamic> j) => UsageWindow(
percent: _i(_any(j, ['percent', 'used'])),
resetsAt: j['resetsAt'] == null ? null : _dt(j['resetsAt']),
);
}
class Usage {
const Usage({required this.known, this.fiveHour, this.week});
final bool known;
final UsageWindow? fiveHour;
final UsageWindow? week;
factory Usage.fromJson(Map<String, dynamic> j) {
Map<String, dynamic>? m(dynamic v) => v is Map ? v.cast<String, dynamic>() : null;
final five = m(j['fiveHour']);
final wk = m(_any(j, ['week', 'sevenDay']));
return Usage(
known: _b(j['limitsKnown']),
fiveHour: five == null ? null : UsageWindow.fromJson(five),
week: wk == null ? null : UsageWindow.fromJson(wk),
);
}
}
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'models.dart';
/// Thrown for anything the UI should show as a message rather than a crash.
class ApiException implements Exception {
ApiException(this.message, {this.statusCode});
final String message;
final int? statusCode;
bool get isAuth => statusCode == 401 || statusCode == 403;
@override
String toString() => message;
}
/// One SSE frame from a run: appended output lines, a status, and whether the
/// run has ended.
class RunEvent {
const RunEvent({this.lines = const [], this.status, this.done = false, this.exitCode});
final List<String> lines;
final String? status;
final bool done;
final int? exitCode;
}
/// The DevPilot API, as Son of Anton uses it.
class SonApi {
SonApi({required String baseUrl, required String token})
: _base = _normalise(baseUrl),
_token = token,
_dio = Dio(BaseOptions(
baseUrl: _normalise(baseUrl),
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 60),
headers: {'Authorization': 'Bearer $token'},
// Never let Dio throw on a status: every call inspects the body, and
// an error body carries a better message than a DioException does.
validateStatus: (_) => true,
responseType: ResponseType.json,
));
final String _base;
final String _token;
final Dio _dio;
String get baseUrl => _base;
static String _normalise(String url) {
var u = url.trim();
if (u.isEmpty) return u;
if (!u.startsWith('http://') && !u.startsWith('https://')) u = 'https://$u';
while (u.endsWith('/')) {
u = u.substring(0, u.length - 1);
}
return u;
}
Never _fail(Response r) {
final data = r.data;
final msg = data is Map ? (data['error'] ?? data['message'])?.toString() : null;
throw ApiException(
msg?.isNotEmpty == true ? msg! : 'Request failed (${r.statusCode})',
statusCode: r.statusCode,
);
}
Map<String, dynamic> _obj(Response r) {
if (r.statusCode != 200) _fail(r);
final d = r.data;
if (d is Map) return d.cast<String, dynamic>();
throw ApiException('Unexpected response shape');
}
List<Map<String, dynamic>> _arr(Response r) {
if (r.statusCode != 200) _fail(r);
final d = r.data;
// Bare arrays and {items:[...]} envelopes are both accepted, because the
// server's list endpoints are not consistent about it.
final list = d is List ? d : (d is Map ? (d['items'] ?? d['results'] ?? d['attachments']) : null);
if (list is! List) return const [];
return [
for (final e in list)
if (e is Map) e.cast<String, dynamic>(),
];
}
Future<Response<dynamic>> _get(String path, [Map<String, dynamic>? q]) async {
try {
return await _dio.get(path, queryParameters: q);
} on DioException catch (e) {
throw ApiException(_network(e));
}
}
Future<Response<dynamic>> _send(String method, String path,
{Object? body, Map<String, dynamic>? q}) async {
try {
return await _dio.request(
path,
data: body,
queryParameters: q,
options: Options(method: method),
);
} on DioException catch (e) {
throw ApiException(_network(e));
}
}
String _network(DioException e) => switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
'The server took too long to answer.',
DioExceptionType.connectionError => 'Cannot reach the server. Check your connection.',
DioExceptionType.badCertificate => 'The server\'s certificate was rejected.',
_ => e.message ?? 'Network error',
};
// --- health ---------------------------------------------------------------
Future<bool> health() async {
final r = await _get('/health');
return r.statusCode == 200;
}
// --- projects + vocabulary ------------------------------------------------
Future<List<Project>> projects() async =>
_arr(await _get('/api/projects')).map(Project.fromJson).toList();
Future<Vocabulary> vocabulary() async {
try {
return Vocabulary.fromJson(_obj(await _get('/api/models')));
} on ApiException {
// An older server has no /api/models. The picker must still work.
return Vocabulary.fallback;
}
}
// --- chats ----------------------------------------------------------------
Future<List<Chat>> chats({String? project}) async => _arr(
await _get('/api/chats', {if (project != null && project.isNotEmpty) 'project': project}),
).map(Chat.fromJson).toList();
Future<Chat> chat(String id) async => Chat.fromJson(_obj(await _get('/api/chats/$id')));
Future<Chat> createChat({
required String project,
String? title,
String? model,
String? effort,
}) async =>
Chat.fromJson(_obj(await _send('POST', '/api/chats', body: {
'project': project,
if (title != null && title.isNotEmpty) 'title': title,
if (model != null) 'model': model,
if (effort != null) 'effort': effort,
})));
Future<Chat> updateChat(
String id, {
String? title,
String? model,
String? effort,
bool? archived,
}) async =>
Chat.fromJson(_obj(await _send('PATCH', '/api/chats/$id', body: {
if (title != null) 'title': title,
if (model != null) 'model': model,
if (effort != null) 'effort': effort,
if (archived != null) 'archived': archived,
})));
Future<void> deleteChat(String id) async => _send('DELETE', '/api/chats/$id');
Future<void> stopChat(String id) async => _send('POST', '/api/chats/$id/stop');
/// Returns the task id of the queued run, which is what the stream follows.
Future<String> sendMessage(
String chatId, {
required String text,
String? model,
String? effort,
List<String> attachments = const [],
}) async {
final j = _obj(await _send('POST', '/api/chats/$chatId/messages', body: {
'text': text,
if (model != null) 'model': model,
if (effort != null) 'effort': effort,
if (attachments.isNotEmpty) 'attachments': attachments,
}));
return (j['taskId'] ?? '').toString();
}
Future<Attachment> uploadAttachment(
String chatId, {
required String name,
required List<int> bytes,
}) async {
final j = _obj(await _send('POST', '/api/chats/$chatId/attachments', body: {
'name': name,
'content': base64Encode(bytes),
}));
return Attachment.fromJson(j);
}
Future<void> deleteAttachment(String chatId, String name) async =>
_send('DELETE', '/api/chats/$chatId/attachments', q: {'name': name});
// --- files ----------------------------------------------------------------
//
// `project` roots the tree; `path` is relative to that project. Sending the
// project on every call is what makes the project selector mean anything.
Future<DirListing> list(String project, String path) async =>
DirListing.fromJson(_obj(await _get('/api/files/list', {'project': project, 'path': path})));
Future<FileContent> read(String project, String path) async =>
FileContent.fromJson(_obj(await _get('/api/files/read', {'project': project, 'path': path})));
Future<void> write(String project, String path, String content) async =>
_send('POST', '/api/files/write', body: {'project': project, 'path': path, 'content': content});
Future<void> createFile(String project, String path, [String content = '']) async =>
_send('POST', '/api/files/create', body: {'project': project, 'path': path, 'content': content});
Future<void> mkdir(String project, String path) async =>
_send('POST', '/api/files/mkdir', body: {'project': project, 'path': path});
Future<void> deletePath(String project, String path) async =>
_send('POST', '/api/files/delete', body: {'project': project, 'path': path});
Future<void> rename(String project, String from, String to) async =>
_send('POST', '/api/files/rename', body: {'project': project, 'from': from, 'to': to});
Future<void> uploadFile(String project, String path, List<int> bytes) async =>
_send('POST', '/api/files/upload',
body: {'project': project, 'path': path, 'content': base64Encode(bytes), 'encoding': 'base64'});
Future<List<FileEntry>> search(String project, String query, [String path = '.']) async =>
_arr(await _get('/api/files/search', {'project': project, 'q': query, 'path': path}))
.map(FileEntry.fromJson)
.toList();
// --- git ------------------------------------------------------------------
Future<List<RepoStatus>> gitStatusAll() async =>
_arr(await _get('/api/git/status-all')).map(RepoStatus.fromJson).toList();
Future<String> gitDiff(String project, [String? file]) async {
final j = _obj(await _get('/api/git/diff', {'project': project, if (file != null) 'file': file}));
return (j['diff'] ?? j['content'] ?? j['text'] ?? '').toString();
}
Future<List<Commit>> gitLog(String project) async {
final j = _obj(await _get('/api/git/log', {'project': project}));
final c = j['commits'];
return c is List
? [
for (final e in c)
if (e is Map) Commit.fromJson(e.cast<String, dynamic>()),
]
: const [];
}
Future<void> gitPull(String project) async => _send('POST', '/api/git/pull', body: {'project': project});
Future<void> gitPush(String project) async => _send('POST', '/api/git/push', body: {'project': project});
Future<void> gitCommit(String project, String message) async =>
_send('POST', '/api/git/commit', body: {'project': project, 'message': message});
Future<void> gitDiscard(String project, String file) async =>
_send('POST', '/api/git/discard', body: {'project': project, 'path': file});
// --- usage ----------------------------------------------------------------
Future<Usage> usage() async => Usage.fromJson(_obj(await _get('/api/usage')));
// --- streaming ------------------------------------------------------------
//
// Read off a raw HttpClient rather than Dio: SSE is a line protocol held open
// indefinitely, and Dio's response handling is built around a body that ends.
// `from` resumes after a reconnect so backgrounding the app does not lose
// output already written.
Stream<RunEvent> streamTask(String taskId, {int from = 0}) =>
_stream('$_base/api/tasks/$taskId/stream?from=$from');
Stream<RunEvent> streamChat(String chatId, {int from = 0}) =>
_stream('$_base/api/chats/$chatId/stream?from=$from');
Stream<RunEvent> _stream(String url) async* {
final client = HttpClient()..idleTimeout = const Duration(minutes: 5);
try {
final req = await client.getUrl(Uri.parse(url));
req.headers.set('Authorization', 'Bearer $_token');
req.headers.set('Accept', 'text/event-stream');
req.headers.set('Cache-Control', 'no-cache');
final res = await req.close();
if (res.statusCode != 200) {
throw ApiException('Stream failed (${res.statusCode})', statusCode: res.statusCode);
}
// SSE frames are separated by a blank line; a frame may carry several
// `data:` lines which concatenate.
final buffer = StringBuffer();
await for (final line in res.transform(utf8.decoder).transform(const LineSplitter())) {
if (line.isEmpty) {
final payload = buffer.toString();
buffer.clear();
if (payload.isEmpty) continue;
final ev = _parseFrame(payload);
if (ev != null) {
yield ev;
if (ev.done) return;
}
continue;
}
if (line.startsWith('data:')) {
buffer.write(line.substring(5).trimLeft());
}
// `event:`/`id:`/`:` keepalive lines are intentionally ignored.
}
} finally {
client.close(force: true);
}
}
RunEvent? _parseFrame(String payload) {
try {
final j = jsonDecode(payload);
if (j is! Map) return null;
final m = j.cast<String, dynamic>();
final raw = m['lines'];
return RunEvent(
lines: raw is List ? raw.map((e) => e.toString()).toList() : const [],
status: m['status']?.toString(),
done: m['done'] == true,
exitCode: m['exitCode'] is int ? m['exitCode'] as int : null,
);
} catch (_) {
// A malformed frame must not kill a live run's stream.
return null;
}
}
}
import 'dart:ui' show Color;
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
/// Local notifications only. There is no FCM and no push infrastructure — the
/// server has nothing to push with, and the only event worth interrupting for
/// is "the run you started has finished", which the app itself observes.
abstract final class Notifications {
static final _plugin = FlutterLocalNotificationsPlugin();
static bool _ready = false;
static const _channel = AndroidNotificationDetails(
'runs',
'Agent runs',
channelDescription: 'Fires when a chat finishes while the app is closed.',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
color: Color(0xFFFF4D1C),
styleInformation: BigTextStyleInformation(''),
);
static Future<void> init() async {
if (_ready) return;
try {
await _plugin.initialize(
const InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
),
);
await _plugin
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();
_ready = true;
} catch (e) {
// A denied permission must never stop the app from starting.
debugPrint('notifications unavailable: $e');
}
}
static Future<void> runFinished({
required String chatTitle,
required String project,
required bool ok,
}) async {
if (!_ready) return;
try {
await _plugin.show(
chatTitle.hashCode & 0x7fffffff,
ok ? 'Finished · $project' : 'Failed · $project',
chatTitle,
const NotificationDetails(android: _channel),
);
} catch (e) {
debugPrint('notify failed: $e');
}
}
}
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api/models.dart';
import 'api/son_api.dart';
/// Credentials + server address, persisted so the app opens straight into work.
class Session {
const Session({this.baseUrl = '', this.token = ''});
final String baseUrl;
final String token;
bool get isAuthed => baseUrl.isNotEmpty && token.isNotEmpty;
}
const _kUrl = 'api_url';
const _kToken = 'api_token';
const _kProject = 'last_project';
/// Default only — the login screen still lets you point at any server, because
/// hardcoding the sole address is what made the previous app undebuggable.
const kDefaultBaseUrl = 'https://devpilot-api.caprover.al-arcade.com';
class SessionNotifier extends StateNotifier<Session> {
SessionNotifier() : super(const Session()) {
_restore();
}
bool _ready = false;
bool get ready => _ready;
Future<void> _restore() async {
final p = await SharedPreferences.getInstance();
state = Session(
baseUrl: p.getString(_kUrl) ?? kDefaultBaseUrl,
token: p.getString(_kToken) ?? '',
);
_ready = true;
}
/// Verifies before persisting: a saved-but-wrong token means every later
/// screen fails with an error the user cannot connect to the login step.
Future<String?> signIn(String baseUrl, String token) async {
final api = SonApi(baseUrl: baseUrl, token: token);
try {
if (!await api.health()) return 'Server rejected that token.';
} on ApiException catch (e) {
return e.isAuth ? 'Server rejected that token.' : e.message;
}
final p = await SharedPreferences.getInstance();
await p.setString(_kUrl, api.baseUrl);
await p.setString(_kToken, token);
state = Session(baseUrl: api.baseUrl, token: token);
return null;
}
Future<void> signOut() async {
final p = await SharedPreferences.getInstance();
await p.remove(_kToken);
state = Session(baseUrl: state.baseUrl, token: '');
}
}
final sessionProvider =
StateNotifierProvider<SessionNotifier, Session>((ref) => SessionNotifier());
final apiProvider = Provider<SonApi>((ref) {
final s = ref.watch(sessionProvider);
return SonApi(baseUrl: s.baseUrl, token: s.token);
});
// --- server data ------------------------------------------------------------
final projectsProvider = FutureProvider<List<Project>>((ref) async {
return ref.watch(apiProvider).projects();
});
final vocabularyProvider = FutureProvider<Vocabulary>((ref) async {
return ref.watch(apiProvider).vocabulary();
});
/// The project the Files and Git tabs are scoped to, remembered across launches.
class SelectedProject extends StateNotifier<String> {
SelectedProject() : super('') {
_restore();
}
Future<void> _restore() async {
final p = await SharedPreferences.getInstance();
state = p.getString(_kProject) ?? '';
}
Future<void> set(String name) async {
state = name;
final p = await SharedPreferences.getInstance();
await p.setString(_kProject, name);
}
}
final selectedProjectProvider =
StateNotifierProvider<SelectedProject, String>((ref) => SelectedProject());
/// Falls back to the first available project so the Files tab is never blank
/// just because nothing has been chosen yet.
final effectiveProjectProvider = Provider<String>((ref) {
final chosen = ref.watch(selectedProjectProvider);
if (chosen.isNotEmpty) return chosen;
final projects = ref.watch(projectsProvider).valueOrNull;
return (projects == null || projects.isEmpty) ? '' : projects.first.name;
});
final usageProvider = FutureProvider<Usage>((ref) async => ref.watch(apiProvider).usage());
final gitStatusProvider = FutureProvider<List<RepoStatus>>((ref) async {
return ref.watch(apiProvider).gitStatusAll();
});
// --- chats ------------------------------------------------------------------
/// The chat list, polled while anything is running so the tab reflects work
/// happening in the background without the user opening each conversation.
class ChatsNotifier extends StateNotifier<AsyncValue<List<Chat>>> {
ChatsNotifier(this._ref) : super(const AsyncValue.loading()) {
refresh();
}
final Ref _ref;
Timer? _poll;
@override
void dispose() {
_poll?.cancel();
super.dispose();
}
Future<void> refresh() async {
try {
final list = await _ref.read(apiProvider).chats();
if (!mounted) return;
state = AsyncValue.data(list);
_schedule(list.any((c) => c.isRunning));
} catch (e, st) {
if (!mounted) return;
// Keep showing the last good list on a transient failure: replacing a
// working screen with an error page over one dropped request is worse
// than briefly stale data.
if (state.valueOrNull == null) state = AsyncValue.error(e, st);
_schedule(true);
}
}
void _schedule(bool active) {
_poll?.cancel();
if (!mounted) return;
_poll = Timer(Duration(seconds: active ? 5 : 20), refresh);
}
Future<Chat> create(String project, {String? model, String? effort}) async {
final c = await _ref.read(apiProvider).createChat(project: project, model: model, effort: effort);
await refresh();
return c;
}
Future<void> delete(String id) async {
// Optimistic: the row disappears immediately, and refresh reconciles.
state = AsyncValue.data([...?state.valueOrNull?.where((c) => c.id != id)]);
await _ref.read(apiProvider).deleteChat(id);
await refresh();
}
Future<void> rename(String id, String title) async {
await _ref.read(apiProvider).updateChat(id, title: title);
await refresh();
}
}
final chatsProvider =
StateNotifierProvider<ChatsNotifier, AsyncValue<List<Chat>>>((ref) => ChatsNotifier(ref));
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// Son of Anton's identity: near-black ground, ember red-orange accent, one
/// warm highlight. Dark only — this is a tool used at night, on a phone, and a
/// light variant would be a second design nobody asked for.
abstract final class Ember {
static const bg = Color(0xFF0B0B0D); // page
static const surface = Color(0xFF141418); // cards, sheets
static const surfaceHi = Color(0xFF1C1C22); // raised: composer, chips
static const border = Color(0xFF26262E);
static const flame = Color(0xFFFF4D1C); // primary accent — the fire
static const flameSoft = Color(0xFFFF7A45); // hover / gradient top
static const amber = Color(0xFFFFB020); // warnings, thinking
static const text = Color(0xFFECECEF);
static const textDim = Color(0xFF9A9AA5);
static const textFaint = Color(0xFF6A6A76);
static const ok = Color(0xFF3FB950);
static const bad = Color(0xFFF85149);
static const info = Color(0xFF58A6FF);
/// The flame gradient, used on the icon, the send button and running states.
static const fire = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [flameSoft, flame],
);
/// Monospace stack for code, diffs and streamed agent output. Android has no
/// JetBrains Mono, so this leans on the platform's own mono face rather than
/// shipping a font file.
static const mono = <String>['monospace', 'Roboto Mono', 'Courier New'];
}
/// Status → colour, in one place so a chat tile, a task row and a git badge
/// never disagree about what "running" looks like.
Color statusColor(String? status) => switch (status) {
'running' => Ember.flame,
'completed' => Ember.ok,
'failed' => Ember.bad,
'cancelled' => Ember.textFaint,
_ => Ember.textDim,
};
ThemeData buildTheme() {
const scheme = ColorScheme.dark(
primary: Ember.flame,
onPrimary: Colors.white,
secondary: Ember.amber,
surface: Ember.surface,
onSurface: Ember.text,
error: Ember.bad,
);
final base = ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: scheme,
scaffoldBackgroundColor: Ember.bg,
canvasColor: Ember.bg,
splashFactory: InkSparkle.splashFactory,
);
return base.copyWith(
appBarTheme: const AppBarTheme(
backgroundColor: Ember.bg,
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: Ember.text,
fontSize: 18,
fontWeight: FontWeight.w600,
),
iconTheme: IconThemeData(color: Ember.text),
systemOverlayStyle: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
),
),
cardTheme: CardThemeData(
color: Ember.surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: Ember.border),
),
margin: EdgeInsets.zero,
),
dividerTheme: const DividerThemeData(color: Ember.border, thickness: 1, space: 1),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Ember.surfaceHi,
hintStyle: const TextStyle(color: Ember.textFaint),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Ember.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Ember.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Ember.flame, width: 1.5),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: Ember.surface,
indicatorColor: Ember.flame.withValues(alpha: 0.16),
elevation: 0,
height: 64,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
iconTheme: WidgetStateProperty.resolveWith(
(s) => IconThemeData(
color: s.contains(WidgetState.selected) ? Ember.flame : Ember.textDim,
size: 22,
),
),
labelTextStyle: WidgetStateProperty.resolveWith(
(s) => TextStyle(
fontSize: 11,
fontWeight: s.contains(WidgetState.selected) ? FontWeight.w600 : FontWeight.w500,
color: s.contains(WidgetState.selected) ? Ember.flame : Ember.textDim,
),
),
),
chipTheme: ChipThemeData(
backgroundColor: Ember.surfaceHi,
side: const BorderSide(color: Ember.border),
labelStyle: const TextStyle(color: Ember.text, fontSize: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: Ember.surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
),
dialogTheme: DialogThemeData(
backgroundColor: Ember.surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: Ember.surfaceHi,
contentTextStyle: const TextStyle(color: Ember.text),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
progressIndicatorTheme: const ProgressIndicatorThemeData(color: Ember.flame),
listTileTheme: const ListTileThemeData(
iconColor: Ember.textDim,
textColor: Ember.text,
),
);
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/flame_mark.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
late final _url = TextEditingController(text: kDefaultBaseUrl);
final _token = TextEditingController();
bool _busy = false;
bool _show = false;
String? _error;
@override
void dispose() {
_url.dispose();
_token.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_busy) return;
final url = _url.text.trim();
final token = _token.text.trim();
if (url.isEmpty || token.isEmpty) {
setState(() => _error = 'Server and token are both required.');
return;
}
setState(() {
_busy = true;
_error = null;
});
final err = await ref.read(sessionProvider.notifier).signIn(url, token);
if (!mounted) return;
setState(() {
_busy = false;
_error = err;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 32),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: FlameMark(size: 76)),
const SizedBox(height: 22),
const Text(
'Son of Anton',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 27, fontWeight: FontWeight.w700, letterSpacing: -0.5),
),
const SizedBox(height: 6),
const Text(
'Drive Claude against your repos, from your pocket.',
textAlign: TextAlign.center,
style: TextStyle(color: Ember.textDim, fontSize: 13),
),
const SizedBox(height: 34),
TextField(
controller: _url,
keyboardType: TextInputType.url,
autocorrect: false,
enabled: !_busy,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(
labelText: 'Server',
prefixIcon: Icon(Icons.dns_outlined, size: 20),
),
),
const SizedBox(height: 12),
TextField(
controller: _token,
obscureText: !_show,
autocorrect: false,
enableSuggestions: false,
enabled: !_busy,
onSubmitted: (_) => _submit(),
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
labelText: 'Access token',
prefixIcon: const Icon(Icons.key_outlined, size: 20),
suffixIcon: IconButton(
icon: Icon(_show ? Icons.visibility_off_outlined : Icons.visibility_outlined,
size: 20),
onPressed: () => setState(() => _show = !_show),
),
),
),
if (_error != null) ...[
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, color: Ember.bad, size: 17),
const SizedBox(width: 8),
Expanded(
child: Text(_error!,
style: const TextStyle(color: Ember.bad, fontSize: 13)),
),
],
),
],
const SizedBox(height: 24),
SizedBox(
height: 50,
child: FilledButton(
onPressed: _busy ? null : _submit,
style: FilledButton.styleFrom(
backgroundColor: Ember.flame,
disabledBackgroundColor: Ember.surfaceHi,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: _busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text('Connect',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
),
),
],
),
),
),
),
),
);
}
}
import 'dart:async';
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 '../../core/api/models.dart';
import '../../core/api/son_api.dart';
import '../../core/notifications.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/agent_output.dart';
/// One conversation. Loads history, then follows the live run over SSE so
/// output appears as the agent produces it rather than on the next poll.
class ChatScreen extends ConsumerStatefulWidget {
const ChatScreen({super.key, required this.chatId});
final String chatId;
@override
ConsumerState<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
final _input = TextEditingController();
final _scroll = ScrollController();
Chat? _chat;
Object? _error;
bool _sending = false;
/// Lines streamed for the run in flight, held separately from history so a
/// refresh cannot duplicate what SSE already delivered.
final List<String> _live = [];
StreamSubscription<RunEvent>? _sub;
Timer? _poll;
String? _model;
String? _effort;
final List<Attachment> _pending = [];
@override
void initState() {
super.initState();
_load(initial: true);
}
@override
void dispose() {
_sub?.cancel();
_poll?.cancel();
_input.dispose();
_scroll.dispose();
super.dispose();
}
SonApi get _api => ref.read(apiProvider);
Future<void> _load({bool initial = false}) async {
try {
final c = await _api.chat(widget.chatId);
if (!mounted) return;
final wasRunning = _chat?.isRunning ?? false;
setState(() {
_chat = c;
_error = null;
if (initial) {
_model = c.model;
_effort = c.effort;
}
});
// A run that ended while we were away is worth a notification: the whole
// workflow is "send it, put the phone away, get told when it lands".
if (wasRunning && !c.isRunning) {
_live.clear();
Notifications.runFinished(
chatTitle: c.title,
project: c.project,
ok: c.status == RunStatus.completed,
);
}
if (c.isRunning) {
_follow();
} else {
_sub?.cancel();
_sub = null;
_poll?.cancel();
}
} catch (e) {
if (!mounted) return;
setState(() => _error = _chat == null ? e : _error);
}
}
/// Follows the chat's newest run. Reconnects on drop, because a phone moving
/// between networks will break a long-lived stream and silence would look
/// exactly like a hung agent.
void _follow() {
if (_sub != null) return;
_sub = _api.streamChat(widget.chatId, from: _live.length).listen(
(ev) {
if (!mounted) return;
setState(() => _live.addAll(ev.lines));
_autoScroll();
if (ev.done) {
_sub?.cancel();
_sub = null;
_load();
}
},
onError: (_) {
_sub = null;
_poll?.cancel();
_poll = Timer(const Duration(seconds: 3), () {
if (mounted && (_chat?.isRunning ?? false)) _follow();
});
},
onDone: () {
_sub = null;
_load();
},
cancelOnError: true,
);
}
void _autoScroll() {
if (!_scroll.hasClients) return;
// Only stick to the bottom if the user is already near it — yanking the
// view while they are reading earlier output is hostile.
final p = _scroll.position;
if (p.maxScrollExtent - p.pixels > 240) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
_scroll.animateTo(
_scroll.position.maxScrollExtent,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
);
}
});
}
Future<void> _send() async {
final text = _input.text.trim();
if (text.isEmpty || _sending) return;
setState(() => _sending = true);
try {
await _api.sendMessage(
widget.chatId,
text: text,
model: _model,
effort: _effort,
attachments: _pending.map((a) => a.name).toList(),
);
_input.clear();
_pending.clear();
_live.clear();
HapticFeedback.lightImpact();
await _load();
_autoScroll();
} on ApiException catch (e) {
if (mounted) _toast(e.message, bad: true);
} finally {
if (mounted) setState(() => _sending = false);
}
}
Future<void> _stop() async {
try {
await _api.stopChat(widget.chatId);
await _load();
} on ApiException catch (e) {
if (mounted) _toast(e.message, bad: true);
}
}
Future<void> _attach() async {
final picked = await FilePicker.platform.pickFiles(withData: true, allowMultiple: true);
if (picked == null || !mounted) return;
for (final f in picked.files) {
final bytes = f.bytes;
if (bytes == null) continue;
try {
final a = await _api.uploadAttachment(widget.chatId, name: f.name, bytes: bytes);
if (mounted) setState(() => _pending.add(a));
} on ApiException catch (e) {
if (mounted) _toast('${f.name}: ${e.message}', bad: true);
}
}
}
void _toast(String msg, {bool bad = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: bad ? Ember.bad.withValues(alpha: 0.9) : Ember.surfaceHi,
),
);
}
Future<void> _rename() async {
final ctl = TextEditingController(text: _chat?.title ?? '');
final name = await showDialog<String>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Rename chat'),
content: TextField(controller: ctl, autofocus: true),
actions: [
TextButton(onPressed: () => Navigator.pop(c), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(c, ctl.text.trim()), child: const Text('Save')),
],
),
);
if (name == null || name.isEmpty) return;
await ref.read(chatsProvider.notifier).rename(widget.chatId, name);
await _load();
}
@override
Widget build(BuildContext context) {
final chat = _chat;
final vocab = ref.watch(vocabularyProvider).valueOrNull ?? Vocabulary.fallback;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(chat?.title ?? 'Chat',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
if (chat != null)
Text(chat.project,
style: const TextStyle(fontSize: 11, color: Ember.textDim)),
],
),
actions: [
if (chat?.isRunning ?? false)
IconButton(
tooltip: 'Stop',
icon: const Icon(Icons.stop_circle_outlined, color: Ember.bad),
onPressed: _stop,
),
IconButton(icon: const Icon(Icons.edit_outlined), onPressed: _rename),
],
),
body: Column(
children: [
Expanded(child: _body(chat)),
_Composer(
controller: _input,
vocab: vocab,
model: _model,
effort: _effort,
pending: _pending,
sending: _sending,
running: chat?.isRunning ?? false,
onModel: (v) => setState(() => _model = v),
onEffort: (v) => setState(() => _effort = v),
onAttach: _attach,
onRemoveAttachment: (a) => setState(() => _pending.remove(a)),
onSend: _send,
onStop: _stop,
),
],
),
);
}
Widget _body(Chat? chat) {
if (chat == null) {
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text('$_error', style: const TextStyle(color: Ember.textDim)),
),
);
}
return const Center(child: CircularProgressIndicator());
}
final msgs = chat.messages;
if (msgs.isEmpty) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: Text(
'Describe the job.\nClaude works in the repo while you do something else.',
textAlign: TextAlign.center,
style: TextStyle(color: Ember.textDim, height: 1.5),
),
),
);
}
return ListView.builder(
controller: _scroll,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 16),
itemCount: msgs.length,
itemBuilder: (_, i) {
final m = msgs[i];
// The final assistant turn shows streamed lines while running; history
// supplies everything else.
final isLast = i == msgs.length - 1;
final live = isLast && !m.isUser && chat.isRunning && _live.isNotEmpty;
return _Bubble(
message: live ? m.copyWith(content: _live.join('\n')) : m,
running: isLast && !m.isUser && chat.isRunning,
);
},
);
}
}
class _Bubble extends StatelessWidget {
const _Bubble({required this.message, required this.running});
final ChatMessage message;
final bool running;
@override
Widget build(BuildContext context) {
if (message.isUser) {
return Align(
alignment: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(bottom: 12, left: 40),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Ember.flame.withValues(alpha: 0.16),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(14),
topRight: Radius.circular(14),
bottomLeft: Radius.circular(14),
bottomRight: Radius.circular(4),
),
border: Border.all(color: Ember.flame.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SelectableText(
message.content,
style: const TextStyle(fontSize: 14, height: 1.4),
),
if (message.attachments.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
children: [
for (final a in message.attachments)
Chip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.attach_file, size: 12),
label: Text(a, style: const TextStyle(fontSize: 10)),
),
],
),
],
],
),
),
);
}
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 14, right: 24),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Ember.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Ember.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (running)
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 2, color: Ember.flame),
)
else
Icon(
message.status == RunStatus.failed
? Icons.error_outline
: Icons.check_circle_outline,
size: 13,
color: message.status == RunStatus.failed ? Ember.bad : Ember.ok,
),
const SizedBox(width: 7),
Text(
running ? 'Working…' : (message.model ?? 'claude'),
style: const TextStyle(fontSize: 11, color: Ember.textDim),
),
if (message.effort != null && message.effort!.isNotEmpty) ...[
const SizedBox(width: 6),
Text('· ${message.effort}',
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
],
const Spacer(),
if (message.content.isNotEmpty)
InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: message.content));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied')),
);
},
child: const Icon(Icons.copy, size: 14, color: Ember.textFaint),
),
],
),
if (message.content.isNotEmpty) ...[
const SizedBox(height: 10),
AgentOutput(text: message.content),
] else if (running) ...[
const SizedBox(height: 10),
const Text('Starting…', style: TextStyle(color: Ember.textFaint, fontSize: 13)),
],
],
),
);
}
}
class _Composer extends StatelessWidget {
const _Composer({
required this.controller,
required this.vocab,
required this.model,
required this.effort,
required this.pending,
required this.sending,
required this.running,
required this.onModel,
required this.onEffort,
required this.onAttach,
required this.onRemoveAttachment,
required this.onSend,
required this.onStop,
});
final TextEditingController controller;
final Vocabulary vocab;
final String? model;
final String? effort;
final List<Attachment> pending;
final bool sending;
final bool running;
final ValueChanged<String?> onModel;
final ValueChanged<String?> onEffort;
final VoidCallback onAttach;
final ValueChanged<Attachment> onRemoveAttachment;
final VoidCallback onSend;
final VoidCallback onStop;
String _label(List<ModelOption> opts, String? id, String fallback) =>
opts.firstWhere((o) => o.id == (id ?? ''), orElse: () => ModelOption(id: '', label: fallback))
.label;
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Ember.surface,
border: Border(top: BorderSide(color: Ember.border)),
),
padding: EdgeInsets.only(
left: 10,
right: 10,
top: 8,
bottom: MediaQuery.of(context).viewPadding.bottom + 8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (pending.isNotEmpty)
Align(
alignment: Alignment.centerLeft,
child: Wrap(
spacing: 6,
children: [
for (final a in pending)
Chip(
visualDensity: VisualDensity.compact,
avatar: const Icon(Icons.attach_file, size: 13),
label: Text(a.name, style: const TextStyle(fontSize: 11)),
onDeleted: () => onRemoveAttachment(a),
deleteIcon: const Icon(Icons.close, size: 13),
),
],
),
),
Row(
children: [
_Selector(
icon: Icons.auto_awesome,
label: _label(vocab.models, model, 'Auto'),
onTap: () => _pick(context, 'Model', vocab.models, model, onModel),
),
const SizedBox(width: 6),
_Selector(
icon: Icons.speed,
label: _label(vocab.efforts, effort, 'Auto'),
onTap: () => _pick(context, 'Effort', vocab.efforts, effort, onEffort),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.attach_file, size: 20),
color: Ember.textDim,
onPressed: onAttach,
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
controller: controller,
maxLines: 6,
minLines: 1,
textCapitalization: TextCapitalization.sentences,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(hintText: 'Give Claude a job…'),
),
),
const SizedBox(width: 8),
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),
),
),
),
],
),
],
),
);
}
void _pick(
BuildContext context,
String title,
List<ModelOption> options,
String? current,
ValueChanged<String?> onPick,
) {
showModalBottomSheet(
context: context,
backgroundColor: Ember.surface,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
for (final o in options)
ListTile(
title: Text(o.label, style: const TextStyle(fontSize: 14)),
subtitle: o.hint.isEmpty
? null
: Text(o.hint, style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
trailing: (current ?? '') == o.id
? const Icon(Icons.check, color: Ember.flame, size: 18)
: null,
onTap: () {
onPick(o.id.isEmpty ? null : o.id);
Navigator.pop(context);
},
),
const SizedBox(height: 8),
],
),
),
);
}
}
class _Selector extends StatelessWidget {
const _Selector({required this.icon, required this.label, required this.onTap});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Ember.surfaceHi,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Ember.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: Ember.flame),
const SizedBox(width: 6),
Text(label, style: const TextStyle(fontSize: 12)),
const Icon(Icons.expand_more, size: 14, color: Ember.textFaint),
],
),
),
);
}
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/providers.dart';
import '../../core/theme.dart';
import '../../widgets/empty_state.dart';
import '../../widgets/flame_mark.dart';
import 'chat_screen.dart';
class ChatsScreen extends ConsumerStatefulWidget {
const ChatsScreen({super.key});
@override
ConsumerState<ChatsScreen> createState() => _ChatsScreenState();
}
class _ChatsScreenState extends ConsumerState<ChatsScreen> {
final _search = TextEditingController();
String _query = '';
String _filter = 'all';
@override
void dispose() {
_search.dispose();
super.dispose();
}
List<Chat> _visible(List<Chat> all) {
final q = _query.trim().toLowerCase();
return all.where((c) {
if (c.archived) return false;
final byFilter = switch (_filter) {
'running' => c.isRunning,
'done' => c.status == RunStatus.completed,
'failed' => c.status == RunStatus.failed,
_ => true,
};
if (!byFilter) return false;
if (q.isEmpty) return true;
return c.title.toLowerCase().contains(q) ||
c.project.toLowerCase().contains(q) ||
c.lastPrompt.toLowerCase().contains(q);
}).toList();
}
Future<void> _newChat() async {
final projects = ref.read(projectsProvider).valueOrNull ?? const <Project>[];
if (projects.isEmpty) return;
final picked = await showModalBottomSheet<String>(
context: context,
backgroundColor: Ember.surface,
builder: (_) => _ProjectPicker(projects: projects),
);
if (picked == null || !mounted) return;
final chat = await ref.read(chatsProvider.notifier).create(picked);
if (!mounted) return;
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ChatScreen(chatId: chat.id)),
);
}
@override
Widget build(BuildContext context) {
final chats = ref.watch(chatsProvider);
return Scaffold(
appBar: AppBar(
title: Row(
children: [
const FlameMark(size: 26),
const SizedBox(width: 10),
const Text('Son of Anton'),
],
),
actions: [
IconButton(
tooltip: 'Refresh',
icon: const Icon(Icons.refresh),
onPressed: () => ref.read(chatsProvider.notifier).refresh(),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _newChat,
backgroundColor: Ember.flame,
foregroundColor: Colors.white,
icon: const Icon(Icons.add),
label: const Text('New chat'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 10),
child: TextField(
controller: _search,
onChanged: (v) => setState(() => _query = v),
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: 'Search chats…',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _query.isEmpty
? null
: IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () {
_search.clear();
setState(() => _query = '');
},
),
),
),
),
SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
for (final f in const [
('all', 'All'),
('running', 'Running'),
('done', 'Done'),
('failed', 'Failed'),
])
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f.$2),
selected: _filter == f.$1,
showCheckmark: false,
selectedColor: Ember.flame.withValues(alpha: 0.18),
side: BorderSide(
color: _filter == f.$1 ? Ember.flame : Ember.border,
),
labelStyle: TextStyle(
fontSize: 12,
color: _filter == f.$1 ? Ember.flame : Ember.textDim,
fontWeight: _filter == f.$1 ? FontWeight.w600 : FontWeight.w500,
),
onSelected: (_) => setState(() => _filter = f.$1),
),
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: chats.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => EmptyState(
icon: Icons.cloud_off,
title: 'Cannot reach the server',
message: '$e',
actionLabel: 'Retry',
onAction: () => ref.read(chatsProvider.notifier).refresh(),
),
data: (all) {
final list = _visible(all);
if (list.isEmpty) {
return EmptyState(
icon: Icons.forum_outlined,
title: all.isEmpty ? 'No chats yet' : 'Nothing matches',
message: all.isEmpty
? 'Start one, hand Claude a job, and close the phone.'
: 'Try a different search or filter.',
actionLabel: all.isEmpty ? 'New chat' : null,
onAction: all.isEmpty ? _newChat : null,
);
}
return RefreshIndicator(
color: Ember.flame,
backgroundColor: Ember.surface,
onRefresh: () => ref.read(chatsProvider.notifier).refresh(),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 96),
itemCount: list.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, i) => _ChatTile(chat: list[i]),
),
);
},
),
),
],
),
);
}
}
class _ChatTile extends ConsumerWidget {
const _ChatTile({required this.chat});
final Chat chat;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Dismissible(
key: ValueKey(chat.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: Ember.bad.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(Icons.delete_outline, color: Ember.bad),
),
confirmDismiss: (_) async =>
await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Delete chat?'),
content: Text(
chat.isRunning
? 'This chat is still running. Deleting it stops the run.'
: 'This removes the conversation and its history.',
),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Delete', style: TextStyle(color: Ember.bad)),
),
],
),
) ??
false,
onDismissed: (_) => ref.read(chatsProvider.notifier).delete(chat.id),
child: Card(
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ChatScreen(chatId: chat.id)),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_StatusDot(status: chat.status),
const SizedBox(width: 9),
Expanded(
child: Text(
chat.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
),
),
Text(
timeago.format(chat.updatedAt, locale: 'en_short'),
style: const TextStyle(color: Ember.textFaint, fontSize: 11),
),
],
),
if (chat.lastPrompt.isNotEmpty) ...[
const SizedBox(height: 7),
Text(
chat.lastPrompt.replaceAll('\n', ' '),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Ember.textDim, fontSize: 13, height: 1.35),
),
],
const SizedBox(height: 11),
Row(
children: [
_Pill(icon: Icons.folder_outlined, text: chat.project),
const SizedBox(width: 6),
if (chat.model != null && chat.model!.isNotEmpty)
_Pill(icon: Icons.auto_awesome, text: chat.model!),
const Spacer(),
Text(
'${chat.messageCount} msg',
style: const TextStyle(color: Ember.textFaint, fontSize: 11),
),
],
),
],
),
),
),
),
);
}
}
class _StatusDot extends StatefulWidget {
const _StatusDot({required this.status});
final RunStatus status;
@override
State<_StatusDot> createState() => _StatusDotState();
}
class _StatusDotState extends State<_StatusDot> with SingleTickerProviderStateMixin {
late final _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final color = statusColor(switch (widget.status) {
RunStatus.running => 'running',
RunStatus.completed => 'completed',
RunStatus.failed => 'failed',
RunStatus.cancelled => 'cancelled',
RunStatus.idle => 'idle',
});
final dot = Container(
width: 9,
height: 9,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
);
// Only a live run pulses; a static dot for everything else keeps the list
// calm instead of twitching in five places at once.
if (widget.status != RunStatus.running) return dot;
return FadeTransition(opacity: _c.drive(Tween(begin: 0.35, end: 1)), child: dot);
}
}
class _Pill extends StatelessWidget {
const _Pill({required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Ember.surfaceHi,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Ember.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 11, color: Ember.textDim),
const SizedBox(width: 5),
Text(text, style: const TextStyle(fontSize: 11, color: Ember.textDim)),
],
),
);
}
class _ProjectPicker extends StatelessWidget {
const _ProjectPicker({required this.projects});
final List<Project> projects;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 14),
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Ember.border,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
const Text('Which repo?',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: projects.length,
itemBuilder: (_, i) => ListTile(
leading: const Icon(Icons.folder_outlined, color: Ember.flame),
title: Text(projects[i].name, style: const TextStyle(fontSize: 14)),
onTap: () => Navigator.pop(context, projects[i].name),
),
),
),
const SizedBox(height: 8),
],
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:re_editor/re_editor.dart';
import 'package:re_highlight/languages/all.dart';
import 'package:re_highlight/styles/atom-one-dark.dart';
import '../../core/api/models.dart';
import '../../core/api/son_api.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import 'mermaid_view.dart';
/// Opens a file: a real code editor for text, a rendered document for markdown,
/// and an honest "binary" panel only when the server actually says so.
class EditorScreen extends ConsumerStatefulWidget {
const EditorScreen({
super.key,
required this.project,
required this.path,
required this.name,
});
final String project;
final String path;
final String name;
@override
ConsumerState<EditorScreen> createState() => _EditorScreenState();
}
class _EditorScreenState extends ConsumerState<EditorScreen> {
final _code = CodeLineEditingController();
FileContent? _file;
Object? _error;
bool _loading = true;
bool _dirty = false;
bool _saving = false;
bool _preview = true; // markdown opens rendered, not as source
SonApi get _api => ref.read(apiProvider);
@override
void initState() {
super.initState();
_load();
_code.addListener(_onChanged);
}
@override
void dispose() {
_code.removeListener(_onChanged);
_code.dispose();
super.dispose();
}
void _onChanged() {
if (!_dirty && _file != null && _code.text != _file!.content) {
setState(() => _dirty = true);
}
}
Future<void> _load() async {
try {
final f = await _api.read(widget.project, widget.path);
if (!mounted) return;
setState(() {
_file = f;
_loading = false;
if (!f.isBinary) _code.text = f.content;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
_loading = false;
});
}
}
Future<void> _save() async {
if (_saving || _file == null) return;
setState(() => _saving = true);
try {
await _api.write(widget.project, widget.path, _code.text);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
_file = FileContent(
path: _file!.path,
name: _file!.name,
content: _code.text,
isBinary: false,
size: _code.text.length,
language: _file!.language,
mimeType: _file!.mimeType,
);
});
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Saved')));
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: Ember.bad.withValues(alpha: 0.9)),
);
}
}
Future<bool> _confirmLeave() async {
if (!_dirty) return true;
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Discard changes?'),
content: const Text('This file has unsaved edits.'),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Keep editing')),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Discard', style: TextStyle(color: Ember.bad)),
),
],
),
);
return ok ?? false;
}
@override
Widget build(BuildContext context) {
final f = _file;
final isMd = f?.isMarkdown ?? false;
return PopScope(
canPop: !_dirty,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
final leave = await _confirmLeave();
if (!leave || !context.mounted) return;
Navigator.pop(context);
},
child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(widget.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
),
if (_dirty)
const Padding(
padding: EdgeInsets.only(left: 6),
child: Icon(Icons.circle, size: 8, color: Ember.flame),
),
],
),
Text(widget.path,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 10, color: Ember.textFaint)),
],
),
actions: [
if (isMd)
IconButton(
tooltip: _preview ? 'View source' : 'Preview',
icon: Icon(_preview ? Icons.code : Icons.visibility_outlined),
onPressed: () => setState(() => _preview = !_preview),
),
if (f != null && !f.isBinary && (!isMd || !_preview))
IconButton(
icon: _saving
? const SizedBox(
width: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Ember.flame))
: Icon(Icons.save_outlined, color: _dirty ? Ember.flame : Ember.textFaint),
onPressed: _dirty && !_saving ? _save : null,
),
],
),
body: _body(f, isMd),
),
);
}
Widget _body(FileContent? f, bool isMd) {
if (_loading) return const Center(child: CircularProgressIndicator());
if (_error != null || f == null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(28),
child: Text('$_error', style: const TextStyle(color: Ember.textDim)),
),
);
}
if (f.isBinary) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.data_object, size: 44, color: Ember.textFaint),
const SizedBox(height: 14),
const Text('Binary file', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text('${f.name} · ${f.size} B',
style: const TextStyle(color: Ember.textDim, fontSize: 12)),
],
),
),
);
}
if (isMd && _preview) return _MarkdownDoc(source: _code.text);
// Real editor: line numbers, selection, find. `language` comes from the
// server, with a filename fallback so an unknown language still highlights.
final langId = _resolveLanguage(f);
return CodeEditor(
controller: _code,
style: CodeEditorStyle(
fontSize: 12.5,
fontFamily: 'monospace',
backgroundColor: Ember.bg,
cursorColor: Ember.flame,
selectionColor: Ember.flame.withValues(alpha: 0.25),
codeTheme: CodeHighlightTheme(
theme: atomOneDarkTheme,
languages: {
if (langId != null) langId: CodeHighlightThemeMode(mode: builtinAllLanguages[langId]!),
},
),
),
indicatorBuilder: (context, controller, chunkController, notifier) => Row(
children: [
DefaultCodeLineNumber(controller: controller, notifier: notifier),
DefaultCodeChunkIndicator(
width: 18, controller: chunkController, notifier: notifier),
],
),
);
}
String? _resolveLanguage(FileContent f) {
if (f.language.isNotEmpty && builtinAllLanguages.containsKey(f.language)) return f.language;
final ext = f.name.contains('.') ? f.name.split('.').last.toLowerCase() : '';
const byExt = {
'dart': 'dart', 'php': 'php', 'js': 'javascript', 'mjs': 'javascript',
'ts': 'typescript', 'py': 'python', 'rb': 'ruby', 'go': 'go', 'rs': 'rust',
'java': 'java', 'kt': 'kotlin', 'sh': 'bash', 'bash': 'bash', 'sql': 'sql',
'html': 'xml', 'xml': 'xml', 'css': 'css', 'scss': 'scss', 'json': 'json',
'yaml': 'yaml', 'yml': 'yaml', 'md': 'markdown',
};
final id = byExt[ext];
return (id != null && builtinAllLanguages.containsKey(id)) ? id : null;
}
}
/// Markdown with mermaid. Fenced ```mermaid blocks are pulled out and rendered
/// as diagrams; everything between them goes through the markdown renderer.
class _MarkdownDoc extends StatelessWidget {
const _MarkdownDoc({required this.source});
final String source;
List<(bool isMermaid, String body)> _split() {
final out = <(bool, String)>[];
final lines = source.split('\n');
final buf = StringBuffer();
final dia = StringBuffer();
var inDiagram = false;
for (final line in lines) {
final t = line.trimLeft();
if (!inDiagram && (t.startsWith('```mermaid') || t.startsWith('~~~mermaid'))) {
if (buf.isNotEmpty) {
out.add((false, buf.toString()));
buf.clear();
}
inDiagram = true;
continue;
}
if (inDiagram && (t.startsWith('```') || t.startsWith('~~~'))) {
out.add((true, dia.toString()));
dia.clear();
inDiagram = false;
continue;
}
(inDiagram ? dia : buf).writeln(line);
}
// An unterminated fence still renders, rather than swallowing the rest.
if (dia.isNotEmpty) out.add((true, dia.toString()));
if (buf.isNotEmpty) out.add((false, buf.toString()));
return out;
}
@override
Widget build(BuildContext context) {
final blocks = _split();
final sheet = MarkdownStyleSheet(
p: const TextStyle(fontSize: 14, height: 1.6, color: Ember.text),
h1: const TextStyle(fontSize: 23, fontWeight: FontWeight.w700, color: Ember.text),
h2: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: Ember.text),
h3: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Ember.text),
code: const TextStyle(
fontSize: 12, fontFamilyFallback: Ember.mono, backgroundColor: Ember.surfaceHi),
codeblockDecoration: BoxDecoration(
color: Ember.surfaceHi,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Ember.border),
),
blockquoteDecoration: const BoxDecoration(
border: Border(left: BorderSide(color: Ember.flame, width: 3)),
),
blockquotePadding: const EdgeInsets.only(left: 12),
a: const TextStyle(color: Ember.info),
listBullet: const TextStyle(color: Ember.flame),
tableBorder: TableBorder.all(color: Ember.border),
tableHead: const TextStyle(fontWeight: FontWeight.w600),
horizontalRuleDecoration: const BoxDecoration(
border: Border(top: BorderSide(color: Ember.border)),
),
);
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 40),
itemCount: blocks.length,
itemBuilder: (_, i) {
final (isMermaid, body) = blocks[i];
if (isMermaid) return MermaidView(source: body.trim());
return MarkdownBody(data: body, styleSheet: sheet, selectable: true);
},
);
}
}
import 'package:file_picker/file_picker.dart';
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';
import '../../widgets/empty_state.dart';
import 'editor_screen.dart';
class FilesScreen extends ConsumerStatefulWidget {
const FilesScreen({super.key});
@override
ConsumerState<FilesScreen> createState() => _FilesScreenState();
}
class _FilesScreenState extends ConsumerState<FilesScreen> {
String _path = '.';
DirListing? _listing;
Object? _error;
bool _loading = false;
String _project = '';
SonApi get _api => ref.read(apiProvider);
@override
Widget build(BuildContext context) {
// The selected project roots the tree. Re-listing when it changes is what
// makes the selector mean something.
final project = ref.watch(effectiveProjectProvider);
if (project != _project) {
_project = project;
_path = '.';
WidgetsBinding.instance.addPostFrameCallback((_) => _list('.'));
}
return Scaffold(
appBar: AppBar(
title: _ProjectButton(project: project),
actions: [
IconButton(icon: const Icon(Icons.search), onPressed: _search),
IconButton(icon: const Icon(Icons.more_vert), onPressed: _menu),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(34),
child: _Breadcrumb(path: _path, onTap: _list),
),
),
body: _body(project),
);
}
Widget _body(String project) {
if (project.isEmpty) {
return const EmptyState(
icon: Icons.folder_off_outlined,
title: 'No repo selected',
message: 'Pick a project to browse its files.',
);
}
if (_loading && _listing == null) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _listing == null) {
return EmptyState(
icon: Icons.error_outline,
title: 'Could not open that folder',
message: '$_error',
actionLabel: 'Retry',
onAction: () => _list(_path),
);
}
final entries = _listing?.entries ?? const <FileEntry>[];
if (entries.isEmpty) {
return const EmptyState(icon: Icons.folder_open, title: 'Empty folder');
}
return RefreshIndicator(
color: Ember.flame,
backgroundColor: Ember.surface,
onRefresh: () => _list(_path),
child: ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
itemCount: entries.length + (_canGoUp ? 1 : 0),
separatorBuilder: (_, __) => const Divider(height: 1, indent: 56),
itemBuilder: (_, i) {
if (_canGoUp && i == 0) {
return ListTile(
leading: const Icon(Icons.arrow_upward, color: Ember.textDim),
title: const Text('..', style: TextStyle(color: Ember.textDim)),
onTap: () => _list(_listing!.parent!),
);
}
final e = entries[i - (_canGoUp ? 1 : 0)];
return _EntryTile(
entry: e,
onTap: () => e.isDir ? _list(e.path) : _open(e),
onLongPress: () => _entryMenu(e),
);
},
),
);
}
bool get _canGoUp => (_listing?.parent ?? '').isNotEmpty;
Future<void> _list(String path) async {
if (_project.isEmpty) return;
setState(() {
_loading = true;
_error = null;
});
try {
final l = await _api.list(_project, path);
if (!mounted) return;
setState(() {
_listing = l;
_path = l.path.isEmpty ? '.' : l.path;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e;
_loading = false;
});
}
}
void _open(FileEntry e) {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => EditorScreen(project: _project, path: e.path, name: e.name),
));
}
Future<void> _search() async {
final ctl = TextEditingController();
final q = await showDialog<String>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Search files'),
content: TextField(
controller: ctl,
autofocus: true,
decoration: const InputDecoration(hintText: 'Filename contains…'),
onSubmitted: (v) => Navigator.pop(c, v),
),
actions: [
TextButton(onPressed: () => Navigator.pop(c), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(c, ctl.text), child: const Text('Search')),
],
),
);
if (q == null || q.trim().isEmpty || !mounted) return;
try {
final results = await _api.search(_project, q.trim());
if (!mounted) return;
showModalBottomSheet(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7,
builder: (_, ctrl) => 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(context);
results[i].isDir ? _list(results[i].path) : _open(results[i]);
},
),
),
),
);
} on ApiException catch (e) {
_toast(e.message, bad: true);
}
}
void _menu() {
showModalBottomSheet(
context: context,
backgroundColor: Ember.surface,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.note_add_outlined),
title: const Text('New file'),
onTap: () {
Navigator.pop(context);
_create(dir: false);
},
),
ListTile(
leading: const Icon(Icons.create_new_folder_outlined),
title: const Text('New folder'),
onTap: () {
Navigator.pop(context);
_create(dir: true);
},
),
ListTile(
leading: const Icon(Icons.upload_file_outlined),
title: const Text('Upload from phone'),
onTap: () {
Navigator.pop(context);
_upload();
},
),
],
),
),
);
}
String _join(String name) => _path == '.' || _path.isEmpty ? name : '$_path/$name';
Future<void> _create({required bool dir}) async {
final name = await _ask(dir ? 'New folder' : 'New file', 'Name');
if (name == null || name.isEmpty) return;
try {
dir ? await _api.mkdir(_project, _join(name)) : await _api.createFile(_project, _join(name));
await _list(_path);
} on ApiException catch (e) {
_toast(e.message, bad: true);
}
}
Future<void> _upload() async {
final picked = await FilePicker.platform.pickFiles(withData: true, allowMultiple: true);
if (picked == null) return;
for (final f in picked.files) {
final bytes = f.bytes;
if (bytes == null) continue;
try {
await _api.uploadFile(_project, _join(f.name), bytes);
} on ApiException catch (e) {
_toast('${f.name}: ${e.message}', bad: true);
}
}
await _list(_path);
}
Future<void> _entryMenu(FileEntry e) async {
showModalBottomSheet(
context: context,
backgroundColor: Ember.surface,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(e.name,
style: const TextStyle(fontWeight: FontWeight.w600),
overflow: TextOverflow.ellipsis),
),
ListTile(
leading: const Icon(Icons.drive_file_rename_outline),
title: const Text('Rename'),
onTap: () async {
Navigator.pop(context);
final name = await _ask('Rename', 'New name', e.name);
if (name == null || name.isEmpty) return;
final parent = e.path.contains('/')
? e.path.substring(0, e.path.lastIndexOf('/'))
: '';
try {
await _api.rename(_project, e.path, parent.isEmpty ? name : '$parent/$name');
await _list(_path);
} on ApiException catch (err) {
_toast(err.message, bad: true);
}
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Ember.bad),
title: const Text('Delete', style: TextStyle(color: Ember.bad)),
onTap: () async {
Navigator.pop(context);
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text('Delete ${e.name}?'),
content: Text(e.isDir
? 'This deletes the folder and everything in it.'
: 'This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Delete', style: TextStyle(color: Ember.bad)),
),
],
),
);
if (ok != true) return;
try {
await _api.deletePath(_project, e.path);
await _list(_path);
} on ApiException catch (err) {
_toast(err.message, bad: true);
}
},
),
],
),
),
);
}
Future<String?> _ask(String title, String hint, [String initial = '']) async {
final ctl = TextEditingController(text: initial);
return showDialog<String>(
context: context,
builder: (c) => AlertDialog(
title: Text(title),
content: TextField(
controller: ctl,
autofocus: true,
decoration: InputDecoration(hintText: hint),
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('OK')),
],
),
);
}
void _toast(String m, {bool bad = false}) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(m),
backgroundColor: bad ? Ember.bad.withValues(alpha: 0.9) : Ember.surfaceHi,
));
}
}
class _ProjectButton extends ConsumerWidget {
const _ProjectButton({required this.project});
final String project;
@override
Widget build(BuildContext context, WidgetRef ref) {
final projects = ref.watch(projectsProvider).valueOrNull ?? const <Project>[];
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: projects.isEmpty
? null
: () async {
final picked = await showModalBottomSheet<String>(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
const Text('Repository',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: projects.length,
itemBuilder: (_, i) => ListTile(
leading: Icon(Icons.folder_outlined,
color: projects[i].name == project ? Ember.flame : Ember.textDim),
title: Text(projects[i].name, style: const TextStyle(fontSize: 14)),
trailing: projects[i].name == project
? const Icon(Icons.check, color: Ember.flame, size: 18)
: null,
onTap: () => Navigator.pop(context, projects[i].name),
),
),
),
const SizedBox(height: 8),
],
),
),
);
if (picked != null) ref.read(selectedProjectProvider.notifier).set(picked);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.folder_outlined, size: 18, color: Ember.flame),
const SizedBox(width: 8),
Flexible(
child: Text(
project.isEmpty ? 'Select repo' : project,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
const Icon(Icons.expand_more, size: 18, color: Ember.textDim),
],
),
),
);
}
}
class _Breadcrumb extends StatelessWidget {
const _Breadcrumb({required this.path, required this.onTap});
final String path;
final ValueChanged<String> onTap;
@override
Widget build(BuildContext context) {
final parts = (path == '.' || path.isEmpty) ? <String>[] : path.split('/');
return Container(
height: 34,
alignment: Alignment.centerLeft,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
InkWell(
onTap: () => onTap('.'),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Icon(Icons.home_outlined, size: 15, color: Ember.textDim),
),
),
for (var i = 0; i < parts.length; i++) ...[
const Icon(Icons.chevron_right, size: 14, color: Ember.textFaint),
InkWell(
onTap: () => onTap(parts.take(i + 1).join('/')),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Text(
parts[i],
style: TextStyle(
fontSize: 12,
color: i == parts.length - 1 ? Ember.text : Ember.textDim,
fontWeight: i == parts.length - 1 ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
],
],
),
);
}
}
class _EntryTile extends StatelessWidget {
const _EntryTile({required this.entry, required this.onTap, required this.onLongPress});
final FileEntry entry;
final VoidCallback onTap;
final VoidCallback onLongPress;
static const _icons = <String, IconData>{
'dart': Icons.flutter_dash,
'php': Icons.php,
'md': Icons.article_outlined,
'json': Icons.data_object,
'yaml': Icons.data_object,
'yml': Icons.data_object,
'png': Icons.image_outlined,
'jpg': Icons.image_outlined,
'svg': Icons.image_outlined,
};
String _size(int b) {
if (b < 1024) return '$b B';
if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB';
return '${(b / 1048576).toStringAsFixed(1)} MB';
}
@override
Widget build(BuildContext context) {
final ext = entry.name.contains('.') ? entry.name.split('.').last.toLowerCase() : '';
return ListTile(
dense: true,
leading: Icon(
entry.isDir ? Icons.folder : (_icons[ext] ?? Icons.description_outlined),
color: entry.isDir ? Ember.flame : Ember.textDim,
size: 21,
),
title: Text(entry.name, style: const TextStyle(fontSize: 14)),
subtitle: entry.isDir ? null : Text(_size(entry.size),
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
trailing: entry.isDir
? const Icon(Icons.chevron_right, size: 18, color: Ember.textFaint)
: null,
onTap: onTap,
onLongPress: onLongPress,
);
}
}
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../core/theme.dart';
/// Renders one mermaid diagram.
///
/// No Dart package draws mermaid, so this hands the source to mermaid.js in a
/// tiny webview. The view reports its rendered height back over a JS handler and
/// the widget sizes itself to it — otherwise every diagram would sit in a
/// fixed-height box, cropped or surrounded by dead space.
class MermaidView extends StatefulWidget {
const MermaidView({super.key, required this.source});
final String source;
@override
State<MermaidView> createState() => _MermaidViewState();
}
class _MermaidViewState extends State<MermaidView> {
double _height = 140;
bool _failed = false;
String get _html {
// The diagram is injected as a JSON string literal, never interpolated into
// markup: mermaid sources contain quotes, angle brackets and newlines that
// would otherwise break out of the element or the script.
final src = jsonEncode(widget.source);
return '''
<!doctype html>
<html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html,body{margin:0;padding:0;background:#141418;overflow-x:auto;}
#d{padding:10px;display:flex;justify-content:center;}
#d svg{max-width:100%;height:auto;}
.err{color:#F85149;font:12px monospace;padding:12px;white-space:pre-wrap;}
</style>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
</head>
<body>
<div id="d"></div>
<script>
function report(){
var h = document.getElementById('d').getBoundingClientRect().height;
window.flutter_inappwebview.callHandler('height', Math.ceil(h) + 20);
}
try {
mermaid.initialize({startOnLoad:false, theme:'dark',
themeVariables:{background:'#141418', primaryColor:'#1C1C22',
primaryTextColor:'#ECECEF', lineColor:'#FF4D1C', fontSize:'13px'}});
mermaid.render('g', $src).then(function(r){
document.getElementById('d').innerHTML = r.svg;
setTimeout(report, 60);
}).catch(function(e){
document.getElementById('d').innerHTML =
'<div class="err">mermaid: ' + (e && e.message ? e.message : e) + '</div>';
setTimeout(report, 60);
});
} catch (e) {
document.getElementById('d').innerHTML = '<div class="err">' + e + '</div>';
setTimeout(report, 60);
}
</script>
</body></html>''';
}
@override
Widget build(BuildContext context) {
if (_failed) {
return _Fallback(source: widget.source);
}
return Container(
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Ember.surface,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Ember.border),
),
clipBehavior: Clip.antiAlias,
height: _height,
child: InAppWebView(
initialData: InAppWebViewInitialData(data: _html, mimeType: 'text/html'),
initialSettings: InAppWebViewSettings(
transparentBackground: true,
disableVerticalScroll: true,
supportZoom: false,
),
onWebViewCreated: (c) {
c.addJavaScriptHandler(
handlerName: 'height',
callback: (args) {
final h = args.isEmpty ? null : double.tryParse('${args.first}');
if (h != null && mounted) {
setState(() => _height = h.clamp(80, 2000));
}
return null;
},
);
},
onReceivedError: (_, __, ___) {
// Offline, or the CDN is unreachable: show the source rather than an
// empty box, so the content is never simply lost.
if (mounted) setState(() => _failed = true);
},
),
);
}
}
class _Fallback extends StatelessWidget {
const _Fallback({required this.source});
final String source;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Ember.surfaceHi,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Ember.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.account_tree_outlined, size: 13, color: Ember.amber),
SizedBox(width: 6),
Text('mermaid (not rendered offline)',
style: TextStyle(fontSize: 11, color: Ember.amber)),
],
),
const SizedBox(height: 8),
SelectableText(
source,
style: const TextStyle(
fontSize: 11.5, height: 1.4, fontFamilyFallback: Ember.mono),
),
],
),
);
}
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';
import '../../widgets/empty_state.dart';
class GitScreen extends ConsumerWidget {
const GitScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final status = ref.watch(gitStatusProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Git'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(gitStatusProvider),
),
],
),
body: status.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => EmptyState(
icon: Icons.cloud_off,
title: 'Cannot read repositories',
message: '$e',
actionLabel: 'Retry',
onAction: () => ref.invalidate(gitStatusProvider),
),
data: (repos) => repos.isEmpty
? const EmptyState(icon: Icons.account_tree_outlined, title: 'No repositories')
: RefreshIndicator(
color: Ember.flame,
backgroundColor: Ember.surface,
onRefresh: () async => ref.invalidate(gitStatusProvider),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
itemCount: repos.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, i) => _RepoCard(repo: repos[i]),
),
),
),
);
}
}
class _RepoCard extends ConsumerStatefulWidget {
const _RepoCard({required this.repo});
final RepoStatus repo;
@override
ConsumerState<_RepoCard> createState() => _RepoCardState();
}
class _RepoCardState extends ConsumerState<_RepoCard> {
bool _busy = false;
SonApi get _api => ref.read(apiProvider);
Future<void> _run(Future<void> Function() action, String done) async {
setState(() => _busy = true);
try {
await action();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(done)));
ref.invalidate(gitStatusProvider);
} on ApiException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message), backgroundColor: Ember.bad.withValues(alpha: 0.9)),
);
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _commit() async {
final ctl = TextEditingController();
final msg = await showDialog<String>(
context: context,
builder: (c) => AlertDialog(
title: Text('Commit ${widget.repo.name}'),
content: TextField(
controller: ctl,
autofocus: true,
maxLines: 3,
decoration: const InputDecoration(hintText: 'Commit message'),
),
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');
}
Future<void> _showDiff() async {
try {
final diff = await _api.gitDiff(widget.repo.name);
if (!mounted) return;
showModalBottomSheet(
context: context,
backgroundColor: Ember.surface,
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.8,
builder: (_, ctrl) => Column(
children: [
const SizedBox(height: 14),
Text('${widget.repo.name} · diff',
style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 10),
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: 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,
),
),
]),
),
),
),
],
),
),
);
} on ApiException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
@override
Widget build(BuildContext context) {
final r = widget.repo;
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.circle, size: 9, color: r.dirty ? Ember.amber : Ember.ok),
const SizedBox(width: 9),
Expanded(
child: Text(r.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
),
if (!r.cloned)
const Text('not cloned',
style: TextStyle(fontSize: 11, color: Ember.bad)),
],
),
const SizedBox(height: 9),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_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),
if (!r.dirty && r.ahead == 0 && r.behind == 0)
_tag(Icons.check, 'clean', Ember.ok),
],
),
const SizedBox(height: 10),
if (_busy)
const LinearProgressIndicator(minHeight: 2, color: Ember.flame)
else
Row(
children: [
_act(Icons.difference_outlined, 'Diff', _showDiff),
_act(Icons.download_outlined, 'Pull',
() => _run(() => _api.gitPull(r.name), 'Pulled')),
_act(Icons.check_circle_outline, 'Commit', r.dirty ? _commit : null),
_act(Icons.upload_outlined, 'Push',
() => _run(() => _api.gitPush(r.name), 'Pushed')),
],
),
],
),
),
);
}
Widget _tag(IconData i, String t, [Color c = Ember.textDim]) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Ember.surfaceHi,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Ember.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(i, size: 11, color: c),
const SizedBox(width: 5),
Text(t, style: TextStyle(fontSize: 11, color: c)),
],
),
);
Widget _act(IconData icon, String label, VoidCallback? onTap) => Expanded(
child: TextButton.icon(
onPressed: onTap,
icon: Icon(icon, size: 15),
label: Text(label, style: const TextStyle(fontSize: 11)),
style: TextButton.styleFrom(
foregroundColor: onTap == null ? Ember.textFaint : Ember.textDim,
padding: const EdgeInsets.symmetric(horizontal: 2),
visualDensity: VisualDensity.compact,
),
),
);
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/models.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import '../../widgets/flame_mark.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(sessionProvider);
final usage = ref.watch(usageProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(usageProvider),
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
const _SectionLabel('Claude usage'),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: usage.when(
loading: () => const Center(
child: Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)),
),
),
error: (e, _) => Text('Unavailable — $e',
style: const TextStyle(color: Ember.textDim, fontSize: 12)),
data: (u) => !u.known
? const Text(
'No limit data yet. It appears after the next run.',
style: TextStyle(color: Ember.textDim, fontSize: 12),
)
: Column(
children: [
_UsageBar(label: 'Five hours', window: u.fiveHour),
const SizedBox(height: 16),
_UsageBar(label: 'Seven days', window: u.week),
],
),
),
),
),
const SizedBox(height: 20),
const _SectionLabel('Connection'),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.dns_outlined),
title: const Text('Server', style: TextStyle(fontSize: 14)),
subtitle: Text(session.baseUrl,
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.logout, color: Ember.bad),
title: const Text('Sign out',
style: TextStyle(fontSize: 14, color: Ember.bad)),
onTap: () async {
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Sign out?'),
content: const Text('The token is removed from this device.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(c, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Sign out',
style: TextStyle(color: Ember.bad)),
),
],
),
);
if (ok == true) ref.read(sessionProvider.notifier).signOut();
},
),
],
),
),
const SizedBox(height: 28),
const Center(child: FlameMark(size: 52)),
const SizedBox(height: 12),
const Center(
child: Text('Son of Anton',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
),
const Center(
child: Text('1.0.0',
style: TextStyle(color: Ember.textFaint, fontSize: 11)),
),
],
),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
text.toUpperCase(),
style: const TextStyle(
fontSize: 11, letterSpacing: 0.8, color: Ember.flame, fontWeight: FontWeight.w600),
),
);
}
class _UsageBar extends StatelessWidget {
const _UsageBar({required this.label, required this.window});
final String label;
final UsageWindow? window;
@override
Widget build(BuildContext context) {
final pct = (window?.percent ?? 0).clamp(0, 100);
// Colour tracks headroom, not brand: the number matters most when it is bad.
final color = pct >= 90 ? Ember.bad : (pct >= 70 ? Ember.amber : Ember.flame);
final reset = window?.resetsAt;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(label, style: const TextStyle(fontSize: 13)),
const Spacer(),
Text('$pct%',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: color)),
],
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: pct / 100,
minHeight: 7,
backgroundColor: Ember.surfaceHi,
valueColor: AlwaysStoppedAnimation(color),
),
),
if (reset != null) ...[
const SizedBox(height: 5),
Text(
'resets ${_when(reset)}',
style: const TextStyle(fontSize: 10, color: Ember.textFaint),
),
],
],
);
}
String _when(DateTime t) {
final d = t.difference(DateTime.now());
if (d.isNegative) return 'shortly';
if (d.inHours >= 24) return 'in ${d.inDays}d ${d.inHours % 24}h';
if (d.inHours >= 1) return 'in ${d.inHours}h ${d.inMinutes % 60}m';
return 'in ${d.inMinutes}m';
}
}
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/theme.dart';
import '../chats/chats_screen.dart';
import '../files/files_screen.dart';
import '../git/git_screen.dart';
import '../settings/settings_screen.dart';
import '../webview/vscode_screen.dart';
/// The five tabs, held in an IndexedStack so each keeps its scroll position,
/// its open directory and its in-flight stream when you switch away — the whole
/// point of the app is leaving work running and coming back to it.
class HomeShell extends ConsumerStatefulWidget {
const HomeShell({super.key});
@override
ConsumerState<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends ConsumerState<HomeShell> {
int _index = 1; // open on Chats: it is what the app is for
static const _tabs = <Widget>[
VsCodeScreen(),
ChatsScreen(),
FilesScreen(),
GitScreen(),
SettingsScreen(),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(index: _index, children: _tabs),
bottomNavigationBar: DecoratedBox(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: Ember.border)),
),
child: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.code_off_outlined),
selectedIcon: Icon(Icons.code),
label: 'VS Code',
),
NavigationDestination(
icon: Icon(Icons.forum_outlined),
selectedIcon: Icon(Icons.forum),
label: 'Chats',
),
NavigationDestination(
icon: Icon(Icons.folder_outlined),
selectedIcon: Icon(Icons.folder),
label: 'Files',
),
NavigationDestination(
icon: Icon(Icons.account_tree_outlined),
selectedIcon: Icon(Icons.account_tree),
label: 'Git',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Settings',
),
],
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
/// code-server in a webview, for the moments the native tools are not enough.
///
/// The address is derived from the API host rather than stored separately: they
/// are the same box, and a second setting is a second thing to get wrong.
class VsCodeScreen extends ConsumerStatefulWidget {
const VsCodeScreen({super.key});
@override
ConsumerState<VsCodeScreen> createState() => _VsCodeScreenState();
}
class _VsCodeScreenState extends ConsumerState<VsCodeScreen> {
InAppWebViewController? _c;
double _progress = 0;
String _url(String apiBase) {
final u = Uri.tryParse(apiBase);
if (u == null) return apiBase;
// devpilot-api.<domain> is only a proxy in front of the box; the editor is
// served by the vscode app on the same root domain.
final host = u.host.replaceFirst(RegExp(r'^devpilot-api\.'), 'vscode.');
return u.replace(host: host, path: '').toString();
}
@override
Widget build(BuildContext context) {
final base = ref.watch(sessionProvider).baseUrl;
final url = _url(base);
return Scaffold(
appBar: AppBar(
title: const Text('VS Code'),
actions: [
IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 17),
onPressed: () async {
if (await _c?.canGoBack() ?? false) _c?.goBack();
},
),
IconButton(icon: const Icon(Icons.refresh), onPressed: () => _c?.reload()),
],
bottom: _progress > 0 && _progress < 1
? PreferredSize(
preferredSize: const Size.fromHeight(2),
child: LinearProgressIndicator(
value: _progress,
minHeight: 2,
backgroundColor: Colors.transparent,
valueColor: const AlwaysStoppedAnimation(Ember.flame),
),
)
: null,
),
body: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(url)),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
useHybridComposition: true,
// code-server is a desktop app in a phone viewport; without this it
// renders at desktop width and is unusable.
useWideViewPort: true,
supportZoom: true,
builtInZoomControls: true,
displayZoomControls: false,
transparentBackground: true,
),
onWebViewCreated: (c) => _c = c,
onProgressChanged: (_, p) => setState(() => _progress = p / 100),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/notifications.dart';
import 'core/providers.dart';
import 'core/theme.dart';
import 'features/auth/login_screen.dart';
import 'features/shell/home_shell.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Ember.surface,
systemNavigationBarIconBrightness: Brightness.light,
));
await Notifications.init();
runApp(const ProviderScope(child: SonOfAntonApp()));
}
class SonOfAntonApp extends ConsumerWidget {
const SonOfAntonApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(sessionProvider);
return MaterialApp(
title: 'Son of Anton',
debugShowCheckedModeBanner: false,
theme: buildTheme(),
// Deliberately not go_router: this app has one auth gate and a tab shell.
// A declarative router here would be indirection with nothing to route.
home: session.isAuthed ? const HomeShell() : const LoginScreen(),
);
}
}
import 'package:flutter/material.dart';
import '../core/theme.dart';
/// Renders the agent's streamed output.
///
/// The server's renderer emits a line vocabulary rather than raw JSON:
/// ▶ session header ■ completion footer
/// [Tool ...] tool invocations
/// ⚠ errors
/// Colouring those makes a long run scannable at a glance, which matters on a
/// phone where you cannot see much at once. Everything else is left verbatim.
class AgentOutput extends StatelessWidget {
const AgentOutput({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
final lines = text.split('\n');
return SelectionArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final line in lines)
if (line.trim().isEmpty)
const SizedBox(height: 6)
else
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(line, style: _styleFor(line)),
),
],
),
);
}
TextStyle _styleFor(String line) {
const base = TextStyle(fontSize: 13, height: 1.45, color: Ember.text);
final t = line.trimLeft();
if (t.startsWith('▶')) {
return base.copyWith(
color: Ember.textFaint,
fontSize: 11,
fontFamilyFallback: Ember.mono,
);
}
if (t.startsWith('■')) {
return base.copyWith(color: Ember.ok, fontSize: 11, fontWeight: FontWeight.w600);
}
if (t.startsWith('⚠') || t.startsWith('Error') || t.startsWith('error')) {
return base.copyWith(color: Ember.bad);
}
if (t.startsWith('[')) {
// Tool calls: monospace and dimmed — they are context, not the answer.
return base.copyWith(
color: Ember.info,
fontSize: 11.5,
fontFamilyFallback: Ember.mono,
);
}
if (t.startsWith('#')) {
return base.copyWith(fontWeight: FontWeight.w700);
}
if (t.startsWith('```') || t.startsWith(' ') || t.startsWith('\t')) {
return base.copyWith(fontFamilyFallback: Ember.mono, fontSize: 12);
}
return base;
}
}
import 'package:flutter/material.dart';
import '../core/theme.dart';
/// One empty/error presentation for every tab, so a failure always offers a way
/// forward instead of a bare spinner that never resolves.
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
required this.icon,
required this.title,
this.message,
this.actionLabel,
this.onAction,
});
final IconData icon;
final String title;
final String? message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 44, color: Ember.textFaint),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
if (message != null) ...[
const SizedBox(height: 8),
Text(
message!,
textAlign: TextAlign.center,
style: const TextStyle(color: Ember.textDim, fontSize: 13, height: 1.45),
),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: 20),
OutlinedButton(
onPressed: onAction,
style: OutlinedButton.styleFrom(
foregroundColor: Ember.flame,
side: const BorderSide(color: Ember.flame),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: Text(actionLabel!),
),
],
],
),
),
);
}
}
import 'package:flutter/material.dart';
import '../core/theme.dart';
/// The app mark: a flame on a dark rounded tile. Drawn rather than shipped as a
/// bitmap so it stays sharp at every size and the gradient matches the theme
/// tokens instead of drifting from them.
class FlameMark extends StatelessWidget {
const FlameMark({super.key, this.size = 40, this.tile = true});
final double size;
final bool tile;
@override
Widget build(BuildContext context) {
final flame = SizedBox(
width: size * 0.56,
height: size * 0.56,
child: CustomPaint(painter: _FlamePainter()),
);
if (!tile) return flame;
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFF121216),
borderRadius: BorderRadius.circular(size * 0.28),
border: Border.all(color: Ember.border),
boxShadow: [
BoxShadow(
color: Ember.flame.withValues(alpha: 0.22),
blurRadius: size * 0.35,
spreadRadius: -size * 0.08,
),
],
),
child: Center(child: flame),
);
}
}
class _FlamePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size s) {
final w = s.width, h = s.height;
// Outer flame: a teardrop with a curled left shoulder.
final outer = Path()
..moveTo(w * 0.50, 0)
..cubicTo(w * 0.86, h * 0.26, w * 1.00, h * 0.46, w * 0.94, h * 0.66)
..cubicTo(w * 0.88, h * 0.89, w * 0.71, h * 1.00, w * 0.50, h * 1.00)
..cubicTo(w * 0.29, h * 1.00, w * 0.12, h * 0.89, w * 0.06, h * 0.66)
..cubicTo(w * 0.00, h * 0.44, w * 0.18, h * 0.30, w * 0.30, h * 0.14)
..cubicTo(w * 0.34, h * 0.34, w * 0.42, h * 0.40, w * 0.46, h * 0.36)
..cubicTo(w * 0.52, h * 0.30, w * 0.50, h * 0.14, w * 0.50, 0)
..close();
canvas.drawPath(
outer,
Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Ember.flameSoft, Ember.flame],
).createShader(Rect.fromLTWH(0, 0, w, h)),
);
// Inner core, brighter and offset low — what reads as heat.
final core = Path()
..moveTo(w * 0.52, h * 0.42)
..cubicTo(w * 0.72, h * 0.56, w * 0.74, h * 0.70, w * 0.62, h * 0.82)
..cubicTo(w * 0.52, h * 0.92, w * 0.34, h * 0.88, w * 0.30, h * 0.74)
..cubicTo(w * 0.27, h * 0.62, w * 0.38, h * 0.54, w * 0.52, h * 0.42)
..close();
canvas.drawPath(
core,
Paint()
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFD68A), Ember.amber],
).createShader(Rect.fromLTWH(0, h * 0.4, w, h * 0.6)),
);
}
@override
bool shouldRepaint(covariant CustomPainter old) => false;
}
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
archive:
dependency: transitive
description:
name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
url: "https://pub.dev"
source: hosted
version: "4.2.0"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev"
source: hosted
version: "1.1.3"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection:
dependency: "direct main"
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec
url: "https://pub.dev"
source: hosted
version: "6.1.5"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.dev"
source: hosted
version: "0.3.5+5"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dbus:
dependency: transitive
description:
name: dbus
sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383
url: "https://pub.dev"
source: hosted
version: "0.7.15"
dio:
dependency: "direct main"
description:
name: dio
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
url: "https://pub.dev"
source: hosted
version: "5.11.1"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "8.3.7"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
url: "https://pub.dev"
source: hosted
version: "0.9.4+1"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
url: "https://pub.dev"
source: hosted
version: "0.9.5+1"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
url: "https://pub.dev"
source: hosted
version: "2.7.0"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
url: "https://pub.dev"
source: hosted
version: "0.9.3+6"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_foreground_task:
dependency: "direct main"
description:
name: flutter_foreground_task
sha256: "206017ee1bf864f34b8d7bce664a172717caa21af8da23f55866470dfe316644"
url: "https://pub.dev"
source: hosted
version: "8.17.0"
flutter_inappwebview:
dependency: "direct main"
description:
name: flutter_inappwebview
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
url: "https://pub.dev"
source: hosted
version: "6.1.5"
flutter_inappwebview_android:
dependency: transitive
description:
name: flutter_inappwebview_android
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
url: "https://pub.dev"
source: hosted
version: "1.1.3"
flutter_inappwebview_internal_annotations:
dependency: transitive
description:
name: flutter_inappwebview_internal_annotations
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
url: "https://pub.dev"
source: hosted
version: "1.3.0"
flutter_inappwebview_ios:
dependency: transitive
description:
name: flutter_inappwebview_ios
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_macos:
dependency: transitive
description:
name: flutter_inappwebview_macos
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_platform_interface:
dependency: transitive
description:
name: flutter_inappwebview_platform_interface
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
url: "https://pub.dev"
source: hosted
version: "1.3.0+1"
flutter_inappwebview_web:
dependency: transitive
description:
name: flutter_inappwebview_web
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_inappwebview_windows:
dependency: transitive
description:
name: flutter_inappwebview_windows
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7"
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610
url: "https://pub.dev"
source: hosted
version: "18.0.1"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52"
url: "https://pub.dev"
source: hosted
version: "8.0.0"
flutter_markdown:
dependency: "direct main"
description:
name: flutter_markdown
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
url: "https://pub.dev"
source: hosted
version: "0.7.7+1"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_riverpod:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.dev"
source: hosted
version: "14.8.1"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e"
url: "https://pub.dev"
source: hosted
version: "4.9.2"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
url: "https://pub.dev"
source: hosted
version: "1.2.3"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "1c0c38790306fda4ed774095620444333e56a2b6bc8fc98f3a35c9398781cf54"
url: "https://pub.dev"
source: hosted
version: "0.8.13+22"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae
url: "https://pub.dev"
source: hosted
version: "0.8.13+7"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
url: "https://pub.dev"
source: hosted
version: "0.19.0"
isolate_contactor:
dependency: transitive
description:
name: isolate_contactor
sha256: "6ba8434ceb58238a1389d6365111a3efe7baa1c68a66f4db6d63d351cf6c3a0f"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
isolate_manager:
dependency: transitive
description:
name: isolate_manager
sha256: "22ed0c25f80ec3b5f21e3a55d060f4650afff33f27c2dff34c0f9409d5759ae5"
url: "https://pub.dev"
source: hosted
version: "4.1.5+1"
jni:
dependency: transitive
description:
name: jni
sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351
url: "https://pub.dev"
source: hosted
version: "1.0.3"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
markdown:
dependency: "direct main"
description:
name: markdown
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
url: "https://pub.dev"
source: hosted
version: "7.3.1"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
url: "https://pub.dev"
source: hosted
version: "1.18.3"
mime:
dependency: "direct main"
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.dev"
source: hosted
version: "2.1.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
re_editor:
dependency: "direct main"
description:
name: re_editor
sha256: "17e430f0591dd361992ec2dd6f69191c1853fa46e05432e095310a8f82ee820e"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
re_highlight:
dependency: "direct main"
description:
name: re_highlight
sha256: "6c4ac3f76f939fb7ca9df013df98526634e17d8f7460e028bd23a035870024f2"
url: "https://pub.dev"
source: hosted
version: "0.0.3"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
riverpod:
dependency: transitive
description:
name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
url: "https://pub.dev"
source: hosted
version: "10.1.4"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
url: "https://pub.dev"
source: hosted
version: "5.0.2"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399"
url: "https://pub.dev"
source: hosted
version: "2.4.28"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.dev"
source: hosted
version: "2.5.7"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
state_notifier:
dependency: transitive
description:
name: state_notifier
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
url: "https://pub.dev"
source: hosted
version: "1.0.0"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
timeago:
dependency: "direct main"
description:
name: timeago
sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e
url: "https://pub.dev"
source: hosted
version: "3.7.1"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
url: "https://pub.dev"
source: hosted
version: "3.2.3"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
uuid:
dependency: transitive
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.dev"
source: hosted
version: "15.3.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks:
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
name: son_of_anton
description: Drive Claude Code against your GitLab repos from your phone.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.8.0
dependencies:
flutter:
sdk: flutter
# State. Riverpod for DI + async state; the whole app is streams of server state.
flutter_riverpod: ^2.6.1
# Networking. Dio for REST; SSE is read off a raw HttpClient stream because
# Dio's response stream buffering fights line-oriented protocols.
dio: ^5.7.0
# Routing
go_router: ^14.6.2
# Editor + syntax highlighting. re_editor gives a real code editor (selection,
# find, line numbers) rather than a SelectableText pretending to be one.
re_editor: ^0.7.0
re_highlight: ^0.0.3
# Markdown rendering. Mermaid blocks are pulled out before this sees them and
# rendered in a webview instead, because no Dart package draws mermaid.
flutter_markdown: ^0.7.4+3
markdown: ^7.2.2
# Webview: the VS Code tab, and the mermaid renderer.
flutter_inappwebview: ^6.1.5
# Background + notifications. No FCM: the server has nothing to push with, and
# a local notification when a run finishes is exactly what's needed.
flutter_local_notifications: ^18.0.1
flutter_foreground_task: ^8.10.4
# Files in and out of the phone.
file_picker: ^8.1.6
image_picker: ^1.1.2
share_plus: ^10.1.3
path_provider: ^2.1.5
# Storage + misc
shared_preferences: ^2.3.3
connectivity_plus: ^6.1.0
intl: ^0.19.0
timeago: ^3.7.0
mime: ^2.0.0
collection: ^1.18.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter_launcher_icons: ^0.14.2
flutter:
uses-material-design: true
assets:
- assets/
flutter_launcher_icons:
android: true
image_path: "assets/icon.png"
adaptive_icon_background: "#0B0B0D"
adaptive_icon_foreground: "assets/icon_foreground.png"
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