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")
This diff is collapsed.
This diff is collapsed.
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)),
),
),
],
),
),
),
),
),
);
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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;
}
This diff is collapsed.
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