Commit 90a71482 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: download, zip/unzip, PDF export, and ten quality-of-life fixes

Files
- Download to the phone (Android's save dialog) or Share — a file, a folder
  as .zip, or a multi-selection. Streamed to disk with progress and Cancel.
- Long-press selects; a selection zips, downloads, shares or deletes. A ⋮
  on every row carries the per-file actions.
- Compress to .zip and Extract, always into a new folder (the server never
  overwrites). Tapping a .zip lists its contents first.
- Export as PDF, made on the phone: markdown (tables, Arabic RTL, repo
  images inlined, mermaid rendered to SVG in a headless WebView because the
  print WebView runs without JavaScript), code as a numbered highlighted
  listing, images, a folder's markdown as one document with contents, and a
  whole chat. Saved to the phone, shared, or put into the repo beside the
  source under a name that never overwrites.

Quality of life
 1. Retry / Edit & resend under a failed run.
 2. Per-chat drafts that survive leaving the chat.
 3. Quick-prompt chips; save your own, long-press to remove.
 4. Screen stays on while a watched run is live (toggle in Settings).
 5. Image preview with pinch and double-tap zoom.
 6. Copy path, and "Ask Claude about this": a new chat, path pre-typed.
 7. Files reopens the folder last used in each project.
 8. Sort by name, date or size; size and age on every row.
 9. Git lists changed files per repo; each opens its own diff with a +/−
    count and Discard for unstaged changes; untracked files show as new.
10. Text size in Settings, app-wide and in the code editor (re_editor
    ignores the app text scale, so it is applied there explicitly).

Fixes
- File and Git actions never read the server's answer. A 200 carrying
  ok:false — a rejected push, a pull that cannot fast-forward — showed
  "Pushed"/"Pulled", and a refused delete said nothing.
- Uploads over 48 MB are refused before sending, with the reason.
- R8 keep rules for printing's android.print bridge; verified in the
  release mapping that PdfConvert and its callbacks keep their names.
- Version 1.4.0+14; Settings no longer claims 1.0.0.

Tests: test/pdf_export_test.dart (8) — span-balanced listing rows, escaping,
language detection, download names, and markdown image paths that cannot
climb out of the project.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 07dbbf6a
...@@ -38,6 +38,8 @@ android { ...@@ -38,6 +38,8 @@ android {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
// Keeps printing's android.print bridge intact under R8 (see the file).
proguardFiles("proguard-rules.pro")
} }
} }
} }
......
# printing's HTML -> PDF path subclasses android.print.PrintDocumentAdapter's
# package-private callbacks, so its bridge class must stay in package
# android.print under its own name. The package ships no consumer rules; without
# these, R8 may rename or move it in a release build and "Export as PDF" would
# fail only in the build that reaches the phone.
-keep class android.print.** { *; }
-keep class net.nfet.flutter.printing.** { *; }
...@@ -285,6 +285,7 @@ class FileEntry { ...@@ -285,6 +285,7 @@ class FileEntry {
required this.size, required this.size,
this.language = '', this.language = '',
this.modified, this.modified,
this.binary = false,
}); });
final String name; final String name;
...@@ -294,6 +295,10 @@ class FileEntry { ...@@ -294,6 +295,10 @@ class FileEntry {
final String language; final String language;
final DateTime? modified; final DateTime? modified;
/// The server's own text/binary call, made from the extension or a NUL-byte
/// probe. Decides whether "Export as PDF" can render it as text.
final bool binary;
factory FileEntry.fromJson(Map<String, dynamic> j) => FileEntry( factory FileEntry.fromJson(Map<String, dynamic> j) => FileEntry(
name: _s(j['name']), name: _s(j['name']),
path: _s(j['path']), path: _s(j['path']),
...@@ -301,6 +306,7 @@ class FileEntry { ...@@ -301,6 +306,7 @@ class FileEntry {
size: _i(j['size']), size: _i(j['size']),
language: _s(_any(j, ['language', 'lang'])), language: _s(_any(j, ['language', 'lang'])),
modified: j['modified'] == null ? null : _dt(j['modified']), modified: j['modified'] == null ? null : _dt(j['modified']),
binary: _b(_any(j, ['binary', 'isBinary'])),
); );
} }
...@@ -367,6 +373,7 @@ class RepoStatus { ...@@ -367,6 +373,7 @@ class RepoStatus {
this.ahead = 0, this.ahead = 0,
this.behind = 0, this.behind = 0,
this.changed = 0, this.changed = 0,
this.staged = const [],
this.modified = const [], this.modified = const [],
this.untracked = const [], this.untracked = const [],
}); });
...@@ -378,10 +385,16 @@ class RepoStatus { ...@@ -378,10 +385,16 @@ class RepoStatus {
final int ahead; final int ahead;
final int behind; final int behind;
final int changed; final int changed;
final List<String> staged;
final List<String> modified; final List<String> modified;
final List<String> untracked; final List<String> untracked;
/// Every tracked path with a change, staged or not — what the Git tab lists
/// to open a per-file diff. A set, because a file can be both.
List<String> get tracked => {...staged, ...modified}.toList();
factory RepoStatus.fromJson(Map<String, dynamic> j) { factory RepoStatus.fromJson(Map<String, dynamic> j) {
final stg = _strings(j['staged']);
final mod = _strings(j['modified']); final mod = _strings(j['modified']);
final unt = _strings(j['untracked']); final unt = _strings(j['untracked']);
return RepoStatus( return RepoStatus(
...@@ -392,12 +405,50 @@ class RepoStatus { ...@@ -392,12 +405,50 @@ class RepoStatus {
ahead: _i(j['ahead']), ahead: _i(j['ahead']),
behind: _i(j['behind']), behind: _i(j['behind']),
changed: _i(_any(j, ['changes', 'count']), mod.length + unt.length), changed: _i(_any(j, ['changes', 'count']), mod.length + unt.length),
staged: stg,
modified: mod, modified: mod,
untracked: unt, untracked: unt,
); );
} }
} }
// --- archives ---------------------------------------------------------------
class ZipEntry {
const ZipEntry({required this.name, required this.size, required this.isDir});
final String name;
final int size;
final bool isDir;
factory ZipEntry.fromJson(Map<String, dynamic> j) =>
ZipEntry(name: _s(j['name']), size: _i(j['size']), isDir: _b(j['isDir']));
}
/// What an archive holds, read without extracting it.
class ZipInfo {
const ZipInfo({
required this.path,
required this.entries,
required this.count,
required this.totalBytes,
required this.truncated,
});
final String path;
final List<ZipEntry> entries;
final int count;
final int totalBytes;
final bool truncated;
factory ZipInfo.fromJson(Map<String, dynamic> j) => ZipInfo(
path: _s(j['path']),
entries: _list(j['entries'], ZipEntry.fromJson),
count: _i(j['count']),
totalBytes: _i(j['totalBytes']),
truncated: _b(j['truncated']),
);
}
class Commit { class Commit {
const Commit({required this.sha, required this.message, required this.author, required this.date}); const Commit({required this.sha, required this.message, required this.author, required this.date});
final String sha; final String sha;
......
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
...@@ -18,6 +19,13 @@ class ApiException implements Exception { ...@@ -18,6 +19,13 @@ class ApiException implements Exception {
String toString() => message; String toString() => message;
} }
/// Cancels a download in flight, without the UI importing Dio.
class DownloadCancel {
final _token = CancelToken();
void cancel() => _token.cancel();
bool get isCancelled => _token.isCancelled;
}
/// One SSE frame from a run: appended output lines, a status, and whether the /// One SSE frame from a run: appended output lines, a status, and whether the
/// run has ended. /// run has ended.
class RunEvent { class RunEvent {
...@@ -244,30 +252,141 @@ class SonApi { ...@@ -244,30 +252,141 @@ class SonApi {
Future<FileContent> read(String project, String path) async => Future<FileContent> read(String project, String path) async =>
FileContent.fromJson(_obj(await _get('/api/files/read', {'project': project, 'path': path}))); FileContent.fromJson(_obj(await _get('/api/files/read', {'project': project, 'path': path})));
/// For anything that changes state. Several endpoints answer 200 with
/// `ok:false` — a rejected push, a pull that cannot fast-forward — and these
/// calls used to return without looking, which is how the Git tab said
/// "Pushed" for a push that never happened and a refused delete said nothing.
Future<Map<String, dynamic>> _act(Future<Response<dynamic>> call) async {
final j = _obj(await call);
if (j['ok'] == false) {
final m = (j['error'] ?? j['message'] ?? j['output'] ?? '').toString().trim();
throw ApiException(m.isEmpty ? 'The server reported a failure.' : m);
}
return j;
}
Future<void> write(String project, String path, String content) async => Future<void> write(String project, String path, String content) async =>
_send('POST', '/api/files/write', body: {'project': project, 'path': path, 'content': content}); _act(_send('POST', '/api/files/write', body: {'project': project, 'path': path, 'content': content}));
Future<void> createFile(String project, String path, [String content = '']) async => Future<void> createFile(String project, String path, [String content = '']) async =>
_send('POST', '/api/files/create', body: {'project': project, 'path': path, 'content': content}); _act(_send('POST', '/api/files/create', body: {'project': project, 'path': path, 'content': content}));
Future<void> mkdir(String project, String path) async => Future<void> mkdir(String project, String path) async =>
_send('POST', '/api/files/mkdir', body: {'project': project, 'path': path}); _act(_send('POST', '/api/files/mkdir', body: {'project': project, 'path': path}));
Future<void> deletePath(String project, String path) async => Future<void> deletePath(String project, String path) async =>
_send('POST', '/api/files/delete', body: {'project': project, 'path': path}); _act(_send('POST', '/api/files/delete', body: {'project': project, 'path': path}));
Future<void> rename(String project, String from, String to) async => Future<void> rename(String project, String from, String to) async =>
_send('POST', '/api/files/rename', body: {'project': project, 'from': from, 'to': to}); _act(_send('POST', '/api/files/rename', body: {'project': project, 'from': from, 'to': to}));
Future<void> uploadFile(String project, String path, List<int> bytes) async => /// Base64 inflates a file by a third, and the server takes a 64 MB body.
_send('POST', '/api/files/upload', static const maxUploadBytes = 48 * 1024 * 1024;
body: {'project': project, 'path': path, 'content': base64Encode(bytes), 'encoding': 'base64'});
Future<void> uploadFile(String project, String path, List<int> bytes) async {
if (bytes.length > maxUploadBytes) {
throw ApiException(
'${path.split('/').last} is ${(bytes.length / 1048576).toStringAsFixed(0)} MB — uploads are limited to 48 MB.');
}
await _act(_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 => Future<List<FileEntry>> search(String project, String query, [String path = '.']) async =>
_arr(await _get('/api/files/search', {'project': project, 'q': query, 'path': path})) _arr(await _get('/api/files/search', {'project': project, 'q': query, 'path': path}))
.map(FileEntry.fromJson) .map(FileEntry.fromJson)
.toList(); .toList();
// Downloads use their own endpoint: /read caps at 1 MB and base64-inflates
// binaries, which is fine for the editor and wrong for getting a file off the
// box. A folder, or several entries of one folder, arrive as a single zip
// that the server builds while it streams.
Map<String, dynamic> _dl(String project, List<String> paths, {bool inline = false}) =>
{'project': project, 'path': paths, if (inline) 'inline': '1'};
/// Into memory: image previews, and PDF assembly.
Future<Uint8List> download(String project, List<String> paths, {bool inline = false}) async {
try {
final r = await _dio.get<List<int>>(
'/api/files/download',
queryParameters: _dl(project, paths, inline: inline),
options: Options(
responseType: ResponseType.bytes,
receiveTimeout: const Duration(minutes: 5),
listFormat: ListFormat.multi,
),
);
if (r.statusCode != 200) _failBytes(r.statusCode, r.data);
return Uint8List.fromList(r.data ?? const []);
} on DioException catch (e) {
throw ApiException(_network(e));
}
}
/// Onto disk, streamed — Download and Share, where the file can be any size.
Future<void> downloadToFile(
String project,
List<String> paths,
String savePath, {
void Function(int received, int total)? onProgress,
DownloadCancel? cancel,
}) async {
try {
final r = await _dio.download(
'/api/files/download',
savePath,
queryParameters: _dl(project, paths),
options: Options(receiveTimeout: const Duration(minutes: 10), listFormat: ListFormat.multi),
onReceiveProgress: onProgress,
cancelToken: cancel?._token,
);
if (r.statusCode != 200) {
// With validateStatus open, the error body was written where the file
// should be. Read the message out of it, then remove it.
final f = File(savePath);
final body = await f.exists() ? await f.readAsBytes() : null;
if (await f.exists()) await f.delete();
_failBytes(r.statusCode, body);
}
} on DioException catch (e) {
throw ApiException(CancelToken.isCancel(e) ? 'Cancelled' : _network(e));
}
}
Never _failBytes(int? status, List<int>? body) {
String? msg;
try {
final j = jsonDecode(utf8.decode(body ?? const []));
if (j is Map) msg = (j['error'] ?? j['message'])?.toString();
} catch (_) {/* not JSON: fall through to the status */}
throw ApiException(
msg?.isNotEmpty == true ? msg! : 'Download failed ($status)',
statusCode: status,
);
}
/// Compresses entries of one folder; returns the new archive's path. The
/// server never reuses an existing name, so this cannot append to an old zip.
Future<String> zip(String project, List<String> paths) async {
final j = _obj(await _send('POST', '/api/files/zip', body: {'project': project, 'paths': paths}));
return (j['path'] ?? '').toString();
}
/// Extracts into a new folder (never an existing one, never overwriting) and
/// returns where it went.
Future<({String path, String? warnings})> unzip(String project, String path, {String? dest}) async {
final j = _obj(await _send('POST', '/api/files/unzip', body: {
'project': project,
'path': path,
if (dest != null && dest.isNotEmpty) 'dest': dest,
}));
return (path: (j['path'] ?? '').toString(), warnings: j['warnings']?.toString());
}
Future<ZipInfo> zipInfo(String project, String path) async =>
ZipInfo.fromJson(_obj(await _get('/api/files/zipinfo', {'project': project, 'path': path})));
// --- git ------------------------------------------------------------------ // --- git ------------------------------------------------------------------
Future<List<RepoStatus>> gitStatusAll() async => Future<List<RepoStatus>> gitStatusAll() async =>
...@@ -289,12 +408,14 @@ class SonApi { ...@@ -289,12 +408,14 @@ class SonApi {
: const []; : const [];
} }
Future<void> gitPull(String project) async => _send('POST', '/api/git/pull', body: {'project': project}); Future<void> gitPull(String project) async =>
Future<void> gitPush(String project) async => _send('POST', '/api/git/push', body: {'project': project}); _act(_send('POST', '/api/git/pull', body: {'project': project}));
Future<void> gitPush(String project) async =>
_act(_send('POST', '/api/git/push', body: {'project': project}));
Future<void> gitCommit(String project, String message) async => Future<void> gitCommit(String project, String message) async =>
_send('POST', '/api/git/commit', body: {'project': project, 'message': message}); _act(_send('POST', '/api/git/commit', body: {'project': project, 'message': message}));
Future<void> gitDiscard(String project, String file) async => Future<void> gitDiscard(String project, String file) async =>
_send('POST', '/api/git/discard', body: {'project': project, 'path': file}); _act(_send('POST', '/api/git/discard', body: {'project': project, 'path': file}));
// --- caprover ------------------------------------------------------------- // --- caprover -------------------------------------------------------------
// //
......
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Shown in Settings. Kept beside pubspec's `version:` by hand — reading it at
/// runtime would need package_info_plus for one string.
const kAppVersion = '1.4.0';
/// On-device conveniences: things that make the app nicer to come back to and
/// that the server has no reason to know.
class Prefs {
static Future<SharedPreferences> get _p => SharedPreferences.getInstance();
// --- chat drafts ----------------------------------------------------------
//
// Half a prompt typed and then a notification tapped used to be half a prompt
// gone. A draft lives until it is sent.
static Future<String> draft(String chatId) async => (await _p).getString('draft.$chatId') ?? '';
static Future<void> setDraft(String chatId, String text) async {
final p = await _p;
if (text.trim().isEmpty) {
await p.remove('draft.$chatId');
} else {
await p.setString('draft.$chatId', text);
}
}
// --- quick prompts --------------------------------------------------------
static const defaultQuickPrompts = <String>[
'Pull the latest first, then ',
'Run the tests and fix anything that fails.',
'Commit and push when you are done.',
'Explain what you changed and why.',
'Keep going.',
];
static Future<List<String>> quickPrompts() async {
final raw = (await _p).getString('quick_prompts');
if (raw == null) return defaultQuickPrompts;
try {
final l = jsonDecode(raw);
if (l is List) return [for (final e in l) e.toString()];
} catch (_) {/* a corrupt value falls back to the defaults */}
return defaultQuickPrompts;
}
static Future<void> setQuickPrompts(List<String> v) async =>
(await _p).setString('quick_prompts', jsonEncode(v));
// --- files ------------------------------------------------------------------
/// The folder last open in each project, so switching repos and back returns
/// to where you were instead of the root.
static Future<String?> lastDir(String project) async => (await _p).getString('last_dir.$project');
static Future<void> setLastDir(String project, String path) async =>
(await _p).setString('last_dir.$project', path);
/// 'name' | 'modified' | 'size'.
static Future<String> fileSort() async => (await _p).getString('file_sort') ?? 'name';
static Future<void> setFileSort(String v) async => (await _p).setString('file_sort', v);
// --- display ----------------------------------------------------------------
/// Keep the screen on while a run you are watching is live. On by default:
/// the screen locking mid-run is what makes a live feed stop being live.
static Future<bool> keepAwake() async => (await _p).getBool('keep_awake') ?? true;
static Future<void> setKeepAwake(bool v) async => (await _p).setBool('keep_awake', v);
}
/// App-wide text size, on top of the phone's own setting. 1.0 leaves it as is.
class TextScaleNotifier extends StateNotifier<double> {
TextScaleNotifier() : super(1.0) {
_restore();
}
static const min = 0.85;
static const max = 1.3;
Future<void> _restore() async {
final p = await SharedPreferences.getInstance();
state = (p.getDouble('text_scale') ?? 1.0).clamp(min, max);
}
Future<void> set(double v) async {
state = v.clamp(min, max);
final p = await SharedPreferences.getInstance();
await p.setDouble('text_scale', state);
}
}
final textScaleProvider =
StateNotifierProvider<TextScaleNotifier, double>((ref) => TextScaleNotifier());
...@@ -4,20 +4,28 @@ import 'package:file_picker/file_picker.dart'; ...@@ -4,20 +4,28 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../../core/api/models.dart'; import '../../core/api/models.dart';
import '../../core/api/son_api.dart'; import '../../core/api/son_api.dart';
import '../../core/notifications.dart'; import '../../core/notifications.dart';
import '../../core/prefs.dart';
import '../../core/providers.dart'; import '../../core/providers.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
import '../../widgets/agent_output.dart'; import '../../widgets/agent_output.dart';
import '../files/pdf_export.dart';
import '../files/pdf_flow.dart';
/// One conversation. Loads history, then follows the live run over SSE so /// 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. /// output appears as the agent produces it rather than on the next poll.
class ChatScreen extends ConsumerStatefulWidget { class ChatScreen extends ConsumerStatefulWidget {
const ChatScreen({super.key, required this.chatId}); const ChatScreen({super.key, required this.chatId, this.draft});
final String chatId; final String chatId;
/// Text to open the composer with — "Ask Claude about this" from the Files
/// tab. Takes precedence over a saved draft.
final String? draft;
@override @override
ConsumerState<ChatScreen> createState() => _ChatScreenState(); ConsumerState<ChatScreen> createState() => _ChatScreenState();
} }
...@@ -57,11 +65,53 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -57,11 +65,53 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
bool _landed = false; bool _landed = false;
bool _showJump = false; bool _showJump = false;
final _focus = FocusNode();
List<String> _quick = const [];
Timer? _draftSave;
bool _keepAwake = true;
bool _awake = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scroll.addListener(_onScroll); _scroll.addListener(_onScroll);
_load(initial: true); _load(initial: true);
_restoreDraft();
_input.addListener(_onDraftChanged);
Prefs.quickPrompts().then((q) {
if (mounted) setState(() => _quick = q);
});
Prefs.keepAwake().then((v) {
_keepAwake = v;
if (mounted) _syncAwake(_chat?.isRunning ?? false);
});
}
/// A draft handed in (Files → "Ask Claude about this") wins over a saved one:
/// it is what was just asked for. Otherwise, the unsent text from last time.
Future<void> _restoreDraft() async {
final saved = await Prefs.draft(widget.chatId);
final given = widget.draft ?? '';
final text = given.isNotEmpty ? given : saved;
if (!mounted || text.isEmpty || _input.text.isNotEmpty) return;
_input.value = TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length));
if (given.isNotEmpty) _focus.requestFocus();
}
void _onDraftChanged() {
_draftSave?.cancel();
_draftSave = Timer(
const Duration(milliseconds: 400), () => Prefs.setDraft(widget.chatId, _input.text));
}
/// Keeps the screen on while a run is live and this chat is open: a phone
/// that locks mid-run stops being a live feed. Released when the run ends or
/// you leave the chat.
void _syncAwake(bool running) {
final want = running && _keepAwake;
if (want == _awake) return;
_awake = want;
want ? WakelockPlus.enable() : WakelockPlus.disable();
} }
/// The jump button earns its place only once you are meaningfully above the /// The jump button earns its place only once you are meaningfully above the
...@@ -97,6 +147,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -97,6 +147,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
void dispose() { void dispose() {
_scroll.removeListener(_onScroll); _scroll.removeListener(_onScroll);
_stopLive(); _stopLive();
_draftSave?.cancel();
_input.removeListener(_onDraftChanged);
// Saved on the way out as well, so text typed in the last 400 ms survives.
Prefs.setDraft(widget.chatId, _input.text);
if (_awake) WakelockPlus.disable();
_focus.dispose();
_input.dispose(); _input.dispose();
_scroll.dispose(); _scroll.dispose();
super.dispose(); super.dispose();
...@@ -127,6 +183,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -127,6 +183,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
ok: c.status == RunStatus.completed, ok: c.status == RunStatus.completed,
); );
} }
_syncAwake(c.isRunning);
if (c.isRunning) { if (c.isRunning) {
// The newest message is the one in flight; both live sources follow it. // The newest message is the one in flight; both live sources follow it.
final last = c.messages.where((m) => !m.isUser).lastOrNull; final last = c.messages.where((m) => !m.isUser).lastOrNull;
...@@ -238,6 +295,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -238,6 +295,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
attachments: _pending.map((a) => a.name).toList(), attachments: _pending.map((a) => a.name).toList(),
); );
_input.clear(); _input.clear();
Prefs.setDraft(widget.chatId, '');
_pending.clear(); _pending.clear();
_liveText = ''; _liveText = '';
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
...@@ -250,6 +308,103 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -250,6 +308,103 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
} }
} }
ChatMessage? get _lastPrompt => _chat?.messages.where((m) => m.isUser).lastOrNull;
/// Sends the failed turn's prompt again — its attachments too — with the
/// model and effort the composer shows now. Retyping it was the only way.
Future<void> _retryRun() async {
final prompt = _lastPrompt;
if (prompt == null || _sending) return;
setState(() => _sending = true);
try {
await _api.sendMessage(
widget.chatId,
text: prompt.content,
model: _model,
effort: _effort,
attachments: prompt.attachments,
);
_liveText = '';
HapticFeedback.lightImpact();
await _load();
_autoScroll();
} on ApiException catch (e) {
if (mounted) _toast(e.message, bad: true);
} finally {
if (mounted) setState(() => _sending = false);
}
}
void _editLast() {
final prompt = _lastPrompt;
if (prompt == null) return;
_input.value = TextEditingValue(
text: prompt.content, selection: TextSelection.collapsed(offset: prompt.content.length));
_focus.requestFocus();
}
void _insertQuick(String p) {
final t = _input.text;
final next = t.isEmpty ? p : (t.endsWith(' ') || t.endsWith('\n') ? '$t$p' : '$t $p');
_input.value = TextEditingValue(text: next, selection: TextSelection.collapsed(offset: next.length));
_focus.requestFocus();
}
Future<void> _addQuick() async {
final ctl = TextEditingController(text: _input.text.trim());
final v = await showDialog<String>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Quick prompt'),
content: TextField(
controller: ctl,
autofocus: true,
minLines: 1,
maxLines: 4,
decoration: const InputDecoration(hintText: 'Text to insert with one tap'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(c), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(c, ctl.text), child: const Text('Save')),
],
),
);
final t = v?.trim() ?? '';
if (t.isEmpty) return;
final next = [..._quick.where((q) => q.trim() != t), t];
setState(() => _quick = next);
await Prefs.setQuickPrompts(next);
}
Future<void> _removeQuick(String p) async {
HapticFeedback.selectionClick();
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Remove this quick prompt?'),
content: Text(p.trim()),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Remove', style: TextStyle(color: Ember.bad)),
),
],
),
);
if (ok != true) return;
final next = [..._quick]..remove(p);
setState(() => _quick = next);
await Prefs.setQuickPrompts(next);
}
Future<void> _exportPdf() async {
final c = _chat;
if (c == null) return;
await PdfFlow.run(context,
fileName: '${safeFileName(c.title)}.pdf', build: (_) => PdfExport.chat(c));
}
/// Persists the composer's pick onto the chat itself. Held only in widget /// Persists the composer's pick onto the chat itself. Held only in widget
/// state it was lost the moment the screen was rebuilt, which is why the next /// state it was lost the moment the screen was rebuilt, which is why the next
/// message silently reverted to Auto. /// message silently reverted to Auto.
...@@ -385,7 +540,27 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -385,7 +540,27 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
), ),
onPressed: _togglePlanFirst, onPressed: _togglePlanFirst,
), ),
IconButton(icon: const Icon(Icons.edit_outlined), onPressed: _rename), PopupMenuButton<String>(
onSelected: (v) => v == 'pdf' ? _exportPdf() : _rename(),
itemBuilder: (_) => const [
PopupMenuItem(
value: 'rename',
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.edit_outlined),
title: Text('Rename')),
),
PopupMenuItem(
value: 'pdf',
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.picture_as_pdf_outlined),
title: Text('Export as PDF')),
),
],
),
], ],
), ),
body: Column( body: Column(
...@@ -436,6 +611,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -436,6 +611,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
onRemoveAttachment: (a) => setState(() => _pending.remove(a)), onRemoveAttachment: (a) => setState(() => _pending.remove(a)),
onSend: _send, onSend: _send,
onStop: _stop, onStop: _stop,
focusNode: _focus,
quick: _quick,
onQuick: _insertQuick,
onAddQuick: _addQuick,
onRemoveQuick: _removeQuick,
), ),
], ],
), ),
...@@ -489,6 +669,49 @@ class _ChatScreenState extends ConsumerState<ChatScreen> { ...@@ -489,6 +669,49 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
message: body == m.content ? m : m.copyWith(content: body), message: body == m.content ? m : m.copyWith(content: body),
running: isLive, running: isLive,
); );
final failedLast = isLast &&
!m.isUser &&
!chat.isRunning &&
(m.status == RunStatus.failed || m.status == RunStatus.cancelled);
if (failedLast) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
bubble,
Padding(
padding: const EdgeInsets.only(bottom: 14, right: 24),
child: Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _sending ? null : _retryRun,
icon: const Icon(Icons.refresh, size: 17),
label: const Text('Retry'),
style: FilledButton.styleFrom(
backgroundColor: Ember.flame,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _sending ? null : _editLast,
icon: const Icon(Icons.edit_note, size: 17),
label: const Text('Edit & resend'),
style: OutlinedButton.styleFrom(
foregroundColor: Ember.textDim,
side: const BorderSide(color: Ember.border),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
),
),
],
),
),
],
);
}
if (!awaitingApproval) return bubble; if (!awaitingApproval) return bubble;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
...@@ -685,8 +908,18 @@ class _Composer extends StatelessWidget { ...@@ -685,8 +908,18 @@ class _Composer extends StatelessWidget {
required this.onRemoveAttachment, required this.onRemoveAttachment,
required this.onSend, required this.onSend,
required this.onStop, required this.onStop,
required this.focusNode,
required this.quick,
required this.onQuick,
required this.onAddQuick,
required this.onRemoveQuick,
}); });
final FocusNode focusNode;
final List<String> quick;
final ValueChanged<String> onQuick;
final VoidCallback onAddQuick;
final ValueChanged<String> onRemoveQuick;
final TextEditingController controller; final TextEditingController controller;
final Vocabulary vocab; final Vocabulary vocab;
final String? model; final String? model;
...@@ -759,12 +992,50 @@ class _Composer extends StatelessWidget { ...@@ -759,12 +992,50 @@ class _Composer extends StatelessWidget {
), ),
], ],
), ),
// Quick prompts: the things typed into this box over and over. Hidden
// while a run is live, when the box cannot send anyway.
if (!running)
SizedBox(
height: 38,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
for (final q in quick)
Padding(
padding: const EdgeInsets.only(right: 6),
child: GestureDetector(
onLongPress: () => onRemoveQuick(q),
child: ActionChip(
visualDensity: VisualDensity.compact,
backgroundColor: Ember.surfaceHi,
side: const BorderSide(color: Ember.border),
label: Text(
q.trim().length > 34 ? '${q.trim().substring(0, 33)}…' : q.trim(),
style: const TextStyle(fontSize: 11.5),
),
onPressed: () => onQuick(q),
),
),
),
ActionChip(
visualDensity: VisualDensity.compact,
backgroundColor: Ember.surfaceHi,
side: const BorderSide(color: Ember.border),
avatar: const Icon(Icons.add, size: 14, color: Ember.flame),
label: const Text('Quick prompt', style: TextStyle(fontSize: 11.5)),
tooltip: 'Save text as a quick prompt. Long-press one to remove it.',
onPressed: onAddQuick,
),
],
),
),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
controller: controller, controller: controller,
focusNode: focusNode,
maxLines: 6, maxLines: 6,
minLines: 1, minLines: 1,
textCapitalization: TextCapitalization.sentences, textCapitalization: TextCapitalization.sentences,
......
...@@ -8,9 +8,12 @@ import 'package:re_highlight/styles/atom-one-dark.dart'; ...@@ -8,9 +8,12 @@ import 'package:re_highlight/styles/atom-one-dark.dart';
import '../../core/api/models.dart'; import '../../core/api/models.dart';
import '../../core/api/son_api.dart'; import '../../core/api/son_api.dart';
import '../../core/prefs.dart';
import '../../core/providers.dart'; import '../../core/providers.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
import 'file_actions.dart';
import 'mermaid_view.dart'; import 'mermaid_view.dart';
import 'pdf_flow.dart';
/// Opens a file: a real code editor for text, a rendered document for markdown, /// 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. /// and an honest "binary" panel only when the server actually says so.
...@@ -109,6 +112,28 @@ class _EditorScreenState extends ConsumerState<EditorScreen> { ...@@ -109,6 +112,28 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
} }
} }
void _more(String v) {
final api = ref.read(apiProvider);
switch (v) {
case 'download':
case 'share':
// These fetch the file from the server, so unsaved edits are not in it.
if (_dirty) toast(context, 'Using the saved version — save first to include your edits.');
v == 'download'
? FileActions.download(context, api,
project: widget.project, paths: [widget.path], fileName: widget.name)
: FileActions.shareRemote(context, api,
project: widget.project, paths: [widget.path], fileName: widget.name);
case 'pdf':
// The text on screen, unsaved edits included.
PdfFlow.forFile(context, api,
project: widget.project, path: widget.path, name: widget.name, source: _code.text);
case 'path':
Clipboard.setData(ClipboardData(text: widget.path));
toast(context, 'Copied ${widget.path}');
}
}
Future<bool> _confirmLeave() async { Future<bool> _confirmLeave() async {
if (!_dirty) return true; if (!_dirty) return true;
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
...@@ -182,6 +207,16 @@ class _EditorScreenState extends ConsumerState<EditorScreen> { ...@@ -182,6 +207,16 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
: Icon(Icons.save_outlined, color: _dirty ? Ember.flame : Ember.textFaint), : Icon(Icons.save_outlined, color: _dirty ? Ember.flame : Ember.textFaint),
onPressed: _dirty && !_saving ? _save : null, onPressed: _dirty && !_saving ? _save : null,
), ),
PopupMenuButton<String>(
onSelected: _more,
itemBuilder: (_) => [
const PopupMenuItem(value: 'download', child: Text('Download to phone')),
const PopupMenuItem(value: 'share', child: Text('Share')),
if (f != null && !f.isBinary)
const PopupMenuItem(value: 'pdf', child: Text('Export as PDF')),
const PopupMenuItem(value: 'path', child: Text('Copy path')),
],
),
], ],
), ),
body: _body(f, isMd), body: _body(f, isMd),
...@@ -211,8 +246,27 @@ class _EditorScreenState extends ConsumerState<EditorScreen> { ...@@ -211,8 +246,27 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
const SizedBox(height: 14), const SizedBox(height: 14),
const Text('Binary file', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Text('Binary file', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 6), const SizedBox(height: 6),
Text('${f.name} · ${f.size} B', Text('${f.name} · ${humanSize(f.size)}',
style: const TextStyle(color: Ember.textDim, fontSize: 12)), style: const TextStyle(color: Ember.textDim, fontSize: 12)),
const SizedBox(height: 20),
Wrap(
spacing: 10,
runSpacing: 10,
alignment: WrapAlignment.center,
children: [
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: Ember.flame),
onPressed: () => _more('download'),
icon: const Icon(Icons.download_outlined, size: 18),
label: const Text('Download'),
),
OutlinedButton.icon(
onPressed: () => _more('share'),
icon: const Icon(Icons.ios_share, size: 18),
label: const Text('Share'),
),
],
),
], ],
), ),
), ),
...@@ -227,7 +281,9 @@ class _EditorScreenState extends ConsumerState<EditorScreen> { ...@@ -227,7 +281,9 @@ class _EditorScreenState extends ConsumerState<EditorScreen> {
return CodeEditor( return CodeEditor(
controller: _code, controller: _code,
style: CodeEditorStyle( style: CodeEditorStyle(
fontSize: 12.5, // re_editor sizes its own text and ignores the app's text scale, so
// the Settings text size is applied here explicitly.
fontSize: 12.5 * ref.watch(textScaleProvider),
fontFamily: 'monospace', fontFamily: 'monospace',
backgroundColor: Ember.bg, backgroundColor: Ember.bg,
cursorColor: Ember.flame, cursorColor: Ember.flame,
......
import 'dart:io';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../../core/api/son_api.dart';
import '../../core/theme.dart';
/// Getting bytes off the server and onto the phone: saved through Android's
/// document picker, or handed to another app through the share sheet.
class FileActions {
/// Android's save dialog takes the whole file as bytes over a platform
/// channel. Past this, Share (which passes a file path) is the safe route.
static const saveLimitBytes = 200 * 1024 * 1024;
/// Where a download is named once it lands: the entry's own name, or a zip
/// for a folder or a multi-selection — matching what the server streams.
static String downloadName(List<String> paths, {required bool isDir, String folderName = 'files'}) {
String base(String p) => p.split('/').where((s) => s.isNotEmpty && s != '.').lastOrNull ?? folderName;
if (paths.length > 1) return '$folderName.zip';
final name = base(paths.single);
return isDir ? '$name.zip' : name;
}
/// Streams [paths] into the app's cache behind a progress dialog with a
/// Cancel button. Returns null if it failed or was cancelled.
static Future<File?> fetch(
BuildContext context,
SonApi api, {
required String project,
required List<String> paths,
required String fileName,
}) async {
final dir = await _scratch();
final file = File('${dir.path}/$fileName');
final progress = ValueNotifier<double?>(null);
final received = ValueNotifier<int>(0);
final cancel = DownloadCancel();
if (!context.mounted) return null;
final nav = Navigator.of(context, rootNavigator: true);
var shown = true;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _ProgressDialog(
name: fileName,
progress: progress,
received: received,
onCancel: cancel.cancel,
),
).whenComplete(() => shown = false);
try {
await api.downloadToFile(
project,
paths,
file.path,
cancel: cancel,
onProgress: (r, t) {
received.value = r;
progress.value = t > 0 ? r / t : null; // a streamed zip has no length
},
);
return file;
} on ApiException catch (e) {
if (!cancel.isCancelled && context.mounted) toast(context, e.message, bad: true);
return null;
} finally {
if (shown) nav.pop();
}
}
/// Download: fetch, then Android's "save as" so the user picks the folder.
static Future<void> download(
BuildContext context,
SonApi api, {
required String project,
required List<String> paths,
required String fileName,
}) async {
final f = await fetch(context, api, project: project, paths: paths, fileName: fileName);
if (f == null || !context.mounted) return;
final size = await f.length();
if (size > saveLimitBytes) {
if (context.mounted) {
toast(context, 'Too large for the save dialog (${humanSize(size)}). Opening Share instead.');
}
await share(f);
return;
}
final bytes = await f.readAsBytes();
if (!context.mounted) return;
await saveBytes(context, fileName: fileName, bytes: bytes);
}
static Future<void> shareRemote(
BuildContext context,
SonApi api, {
required String project,
required List<String> paths,
required String fileName,
}) async {
final f = await fetch(context, api, project: project, paths: paths, fileName: fileName);
if (f != null) await share(f);
}
static Future<void> saveBytes(
BuildContext context, {
required String fileName,
required Uint8List bytes,
}) async {
try {
final where = await FilePicker.platform.saveFile(fileName: fileName, bytes: bytes);
if (where != null && context.mounted) toast(context, 'Saved $fileName');
} catch (e) {
if (context.mounted) toast(context, 'Could not save: $e', bad: true);
}
}
static Future<void> share(File f) => Share.shareXFiles([XFile(f.path)]);
/// Writes bytes made on the phone (a PDF) somewhere Share can read them.
static Future<File> writeTemp(String fileName, Uint8List bytes) async {
final f = File('${(await _scratch()).path}/$fileName');
await f.writeAsBytes(bytes, flush: true);
return f;
}
/// A fresh folder per download, so two files with one name never collide,
/// and yesterday's downloads are cleared on the way in rather than piling up
/// in the cache.
static Future<Directory> _scratch() async {
final root = Directory('${(await getTemporaryDirectory()).path}/downloads');
if (await root.exists()) {
final cutoff = DateTime.now().subtract(const Duration(hours: 6));
await for (final e in root.list()) {
try {
if ((await e.stat()).modified.isBefore(cutoff)) await e.delete(recursive: true);
} catch (_) {/* in use by a share still open: next time */}
}
}
final d = Directory('${root.path}/${DateTime.now().microsecondsSinceEpoch}');
await d.create(recursive: true);
return d;
}
}
String humanSize(int b) {
if (b < 1024) return '$b B';
if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB';
if (b < 1024 * 1024 * 1024) return '${(b / 1048576).toStringAsFixed(1)} MB';
return '${(b / 1073741824).toStringAsFixed(2)} GB';
}
void toast(BuildContext context, String m, {bool bad = false}) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(m),
backgroundColor: bad ? Ember.bad.withValues(alpha: 0.9) : Ember.surfaceHi,
));
}
class _ProgressDialog extends StatelessWidget {
const _ProgressDialog({
required this.name,
required this.progress,
required this.received,
required this.onCancel,
});
final String name;
final ValueNotifier<double?> progress;
final ValueNotifier<int> received;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
child: AlertDialog(
title: const Text('Downloading', style: TextStyle(fontSize: 16)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, color: Ember.textDim)),
const SizedBox(height: 14),
ValueListenableBuilder<double?>(
valueListenable: progress,
builder: (_, v, __) => LinearProgressIndicator(value: v, color: Ember.flame),
),
const SizedBox(height: 8),
ValueListenableBuilder<int>(
valueListenable: received,
builder: (_, r, __) => Text(humanSize(r),
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
),
],
),
actions: [TextButton(onPressed: onCancel, child: const Text('Cancel'))],
),
);
}
}
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:timeago/timeago.dart' as timeago;
import '../../core/api/models.dart'; import '../../core/api/models.dart';
import '../../core/api/son_api.dart'; import '../../core/api/son_api.dart';
import '../../core/prefs.dart';
import '../../core/providers.dart'; import '../../core/providers.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
import '../../widgets/empty_state.dart'; import '../../widgets/empty_state.dart';
import '../chats/chat_screen.dart';
import 'editor_screen.dart'; import 'editor_screen.dart';
import 'file_actions.dart';
import 'image_viewer.dart';
import 'pdf_export.dart';
import 'pdf_flow.dart';
class FilesScreen extends ConsumerStatefulWidget { class FilesScreen extends ConsumerStatefulWidget {
const FilesScreen({super.key}); const FilesScreen({super.key});
...@@ -21,9 +29,27 @@ class _FilesScreenState extends ConsumerState<FilesScreen> { ...@@ -21,9 +29,27 @@ class _FilesScreenState extends ConsumerState<FilesScreen> {
DirListing? _listing; DirListing? _listing;
Object? _error; Object? _error;
bool _loading = false; bool _loading = false;
bool _busy = false;
String _project = ''; String _project = '';
String _sort = 'name';
/// Paths picked by long-press. Non-empty is selection mode.
final Set<String> _selected = {};
SonApi get _api => ref.read(apiProvider); SonApi get _api => ref.read(apiProvider);
bool get _selecting => _selected.isNotEmpty;
bool get _canGoUp => (_listing?.parent ?? '').isNotEmpty;
/// What a download of this folder, or of a multi-selection in it, is named.
String get _folderName => _path == '.' || _path.isEmpty ? _project : _path.split('/').last;
@override
void initState() {
super.initState();
Prefs.fileSort().then((s) {
if (mounted) setState(() => _sort = s);
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
...@@ -33,26 +59,108 @@ class _FilesScreenState extends ConsumerState<FilesScreen> { ...@@ -33,26 +59,108 @@ class _FilesScreenState extends ConsumerState<FilesScreen> {
if (project != _project) { if (project != _project) {
_project = project; _project = project;
_path = '.'; _path = '.';
WidgetsBinding.instance.addPostFrameCallback((_) => _list('.')); _listing = null;
_selected.clear();
WidgetsBinding.instance.addPostFrameCallback((_) => _restore());
} }
final entries = _sorted(_listing?.entries ?? const <FileEntry>[]);
return PopScope(
// Back leaves selection mode before it leaves anything else.
canPop: !_selecting,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) setState(_selected.clear);
},
child: Scaffold(
appBar: _selecting ? _selectionBar(entries) : _normalBar(project),
body: _body(project, entries),
),
);
}
// --- bars -----------------------------------------------------------------
return Scaffold( PreferredSizeWidget _bottom() => PreferredSize(
appBar: AppBar( preferredSize: const Size.fromHeight(36),
child: Column(
children: [
_Breadcrumb(path: _path, onTap: _list),
SizedBox(
height: 2,
child: _busy ? const LinearProgressIndicator(minHeight: 2, color: Ember.flame) : null,
),
],
),
);
AppBar _normalBar(String project) => AppBar(
title: _ProjectButton(project: project), title: _ProjectButton(project: project),
actions: [ actions: [
IconButton(icon: const Icon(Icons.search), onPressed: _search), IconButton(tooltip: 'Search', icon: const Icon(Icons.search), onPressed: _search),
IconButton(icon: const Icon(Icons.more_vert), onPressed: _menu), PopupMenuButton<String>(
tooltip: 'Sort',
icon: const Icon(Icons.sort),
onSelected: (v) {
setState(() => _sort = v);
Prefs.setFileSort(v);
},
itemBuilder: (_) => [
for (final (id, label) in const [
('name', 'Name'),
('modified', 'Recently changed'),
('size', 'Largest first'),
])
CheckedPopupMenuItem(value: id, checked: _sort == id, child: Text(label)),
],
),
IconButton(tooltip: 'More', icon: const Icon(Icons.more_vert), onPressed: _menu),
], ],
bottom: PreferredSize( bottom: _bottom(),
preferredSize: const Size.fromHeight(34), );
child: _Breadcrumb(path: _path, onTap: _list),
), AppBar _selectionBar(List<FileEntry> entries) {
final picked = entries.where((e) => _selected.contains(e.path)).toList();
return AppBar(
leading: IconButton(
tooltip: 'Done',
icon: const Icon(Icons.close),
onPressed: () => setState(_selected.clear),
), ),
body: _body(project), title: Text('${_selected.length} selected',
overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16)),
actions: [
IconButton(
tooltip: 'Compress to .zip',
icon: const Icon(Icons.folder_zip_outlined),
onPressed: () => _compress(_selected.toList()),
),
IconButton(
tooltip: 'Download',
icon: const Icon(Icons.download_outlined),
onPressed: () => _download(picked),
),
IconButton(
tooltip: 'Delete',
icon: const Icon(Icons.delete_outline, color: Ember.bad),
onPressed: () => _delete(picked),
),
PopupMenuButton<String>(
onSelected: (v) => v == 'all'
? setState(() => _selected.addAll(entries.map((e) => e.path)))
: _share(picked),
itemBuilder: (_) => const [
PopupMenuItem(value: 'all', child: Text('Select all')),
PopupMenuItem(value: 'share', child: Text('Share')),
],
),
],
bottom: _bottom(),
); );
} }
Widget _body(String project) { // --- list -----------------------------------------------------------------
Widget _body(String project, List<FileEntry> entries) {
if (project.isEmpty) { if (project.isEmpty) {
return const EmptyState( return const EmptyState(
icon: Icons.folder_off_outlined, icon: Icons.folder_off_outlined,
...@@ -72,266 +180,497 @@ class _FilesScreenState extends ConsumerState<FilesScreen> { ...@@ -72,266 +180,497 @@ class _FilesScreenState extends ConsumerState<FilesScreen> {
onAction: () => _list(_path), onAction: () => _list(_path),
); );
} }
final entries = _listing?.entries ?? const <FileEntry>[];
if (entries.isEmpty) {
return const EmptyState(icon: Icons.folder_open, title: 'Empty folder');
}
final up = _canGoUp ? 1 : 0;
return RefreshIndicator( return RefreshIndicator(
color: Ember.flame, color: Ember.flame,
backgroundColor: Ember.surface, backgroundColor: Ember.surface,
onRefresh: () => _list(_path), onRefresh: () => _list(_path),
child: ListView.separated( child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
itemCount: entries.length + (_canGoUp ? 1 : 0), itemCount: up + (entries.isEmpty ? 1 : entries.length),
separatorBuilder: (_, __) => const Divider(height: 1, indent: 56), separatorBuilder: (_, __) => const Divider(height: 1, indent: 56),
itemBuilder: (_, i) { itemBuilder: (_, i) {
if (_canGoUp && i == 0) { if (up == 1 && i == 0) {
return ListTile( return ListTile(
leading: const Icon(Icons.arrow_upward, color: Ember.textDim), leading: const Icon(Icons.arrow_upward, color: Ember.textDim),
title: const Text('..', style: TextStyle(color: Ember.textDim)), title: const Text('..', style: TextStyle(color: Ember.textDim)),
onTap: () => _list(_listing!.parent!), onTap: () => _list(_listing!.parent!),
); );
} }
final e = entries[i - (_canGoUp ? 1 : 0)]; if (entries.isEmpty) {
return const ListTile(
title: Text('Empty folder', style: TextStyle(fontSize: 13, color: Ember.textFaint)),
);
}
final e = entries[i - up];
return _EntryTile( return _EntryTile(
entry: e, entry: e,
onTap: () => e.isDir ? _list(e.path) : _open(e), selecting: _selecting,
onLongPress: () => _entryMenu(e), selected: _selected.contains(e.path),
onTap: () => _selecting ? _toggle(e) : _openEntry(e),
onLongPress: () => _toggle(e),
onMenu: () => _entryMenu(e),
); );
}, },
), ),
); );
} }
bool get _canGoUp => (_listing?.parent ?? '').isNotEmpty; List<FileEntry> _sorted(List<FileEntry> es) {
int by(FileEntry a, FileEntry b) {
final name = a.name.toLowerCase().compareTo(b.name.toLowerCase());
return switch (_sort) {
'modified' => (b.modified ?? DateTime(0)).compareTo(a.modified ?? DateTime(0)),
'size' => a.isDir ? name : b.size.compareTo(a.size),
_ => name,
};
}
Future<void> _list(String path) async { // Folders stay on top whatever the order: they are how you move around.
if (_project.isEmpty) return; return [...es]..sort((a, b) => a.isDir != b.isDir ? (a.isDir ? -1 : 1) : by(a, b));
}
/// Opens the folder last used in this project, falling back to the root when
/// it has since been deleted or renamed.
Future<void> _restore() async {
final last = await Prefs.lastDir(_project);
if (last != null && last.isNotEmpty && last != '.' && await _list(last, quiet: true)) return;
await _list('.');
}
Future<bool> _list(String path, {bool quiet = false}) async {
if (_project.isEmpty) return false;
final project = _project;
setState(() { setState(() {
_loading = true; _loading = true;
_error = null; if (!quiet) _error = null;
}); });
try { try {
final l = await _api.list(_project, path); final l = await _api.list(project, path);
if (!mounted) return; if (!mounted || project != _project) return false;
setState(() { setState(() {
_listing = l; _listing = l;
_path = l.path.isEmpty ? '.' : l.path; _path = l.path.isEmpty ? '.' : l.path;
_loading = false; _loading = false;
_error = null;
_selected.clear();
}); });
Prefs.setLastDir(project, _path);
return true;
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return false;
setState(() { setState(() {
_error = e;
_loading = false; _loading = false;
if (!quiet && _listing == null) _error = e;
}); });
if (!quiet && _listing != null) toast(context, '$e', bad: true);
return false;
} }
} }
void _open(FileEntry e) { void _toggle(FileEntry e) {
Navigator.of(context).push(MaterialPageRoute( HapticFeedback.selectionClick();
builder: (_) => EditorScreen(project: _project, path: e.path, name: e.name), setState(() {
)); if (!_selected.remove(e.path)) _selected.add(e.path);
});
} }
Future<void> _search() async { void _openEntry(FileEntry e) {
final ctl = TextEditingController(); if (e.isDir) {
final q = await showDialog<String>( _list(e.path);
return;
}
if (isZipName(e.name)) {
_zipContents(e);
return;
}
final page = isImageName(e.name)
? ImageViewerScreen(project: _project, path: e.path, name: e.name)
: EditorScreen(project: _project, path: e.path, name: e.name);
// Back from the editor may be back from a save: sizes and dates moved.
Navigator.of(context)
.push(MaterialPageRoute(builder: (_) => page))
.then((_) => _list(_path, quiet: true));
}
// --- menus ----------------------------------------------------------------
void _sheet(List<_Act> acts, {Widget? header}) {
showModalBottomSheet<void>(
context: context, context: context,
builder: (c) => AlertDialog( backgroundColor: Ember.surface,
title: const Text('Search files'), isScrollControlled: true,
content: TextField( builder: (sheet) => SafeArea(
controller: ctl, child: ConstrainedBox(
autofocus: true, constraints: BoxConstraints(maxHeight: MediaQuery.of(sheet).size.height * 0.85),
decoration: const InputDecoration(hintText: 'Filename contains…'), child: SingleChildScrollView(
onSubmitted: (v) => Navigator.pop(c, v), child: Column(
mainAxisSize: MainAxisSize.min,
children: [
?header,
for (final a in acts)
ListTile(
dense: true,
leading: Icon(a.icon, color: a.danger ? Ember.bad : null),
title: Text(a.label,
style: TextStyle(fontSize: 14, color: a.danger ? Ember.bad : null)),
onTap: () {
Navigator.pop(sheet);
a.run();
},
),
],
),
),
), ),
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; }
void _menu() => _sheet([
_Act(Icons.note_add_outlined, 'New file', () => _create(dir: false)),
_Act(Icons.create_new_folder_outlined, 'New folder', () => _create(dir: true)),
_Act(Icons.upload_file_outlined, 'Upload from phone', _upload),
_Act(Icons.download_outlined, 'Download this folder (.zip)',
() => _downloadPaths([_path], isDir: true)),
_Act(Icons.picture_as_pdf_outlined, 'Export docs here as one PDF',
() => _folderPdf(_path, _folderName)),
]);
void _entryMenu(FileEntry e) {
final text = !e.isDir && (!e.binary || PdfExport.isMarkdown(e.name));
final pdf = e.isDir || text || isImageName(e.name);
final zip = isZipName(e.name);
_sheet(header: _MenuHeader(entry: e), [
if (!e.isDir) _Act(Icons.open_in_new, zip ? 'View contents' : 'Open', () => _openEntry(e)),
if (zip) _Act(Icons.unarchive_outlined, 'Extract here…', () => _extract(e)),
_Act(Icons.download_outlined, e.isDir ? 'Download as .zip' : 'Download to phone',
() => _download([e])),
_Act(Icons.ios_share, e.isDir ? 'Share as .zip' : 'Share', () => _share([e])),
if (pdf)
_Act(Icons.picture_as_pdf_outlined, e.isDir ? 'Export docs as one PDF' : 'Export as PDF',
() => e.isDir ? _folderPdf(e.path, e.name) : _filePdf(e)),
if (!zip) _Act(Icons.folder_zip_outlined, 'Compress to .zip', () => _compress([e.path])),
_Act(Icons.check_box_outlined, 'Select', () => _toggle(e)),
_Act(Icons.link, 'Copy path', () {
Clipboard.setData(ClipboardData(text: e.path));
toast(context, 'Copied ${e.path}');
}),
_Act(Icons.auto_awesome_outlined, 'Ask Claude about this', () => _askClaude(e)),
_Act(Icons.drive_file_rename_outline, 'Rename', () => _rename(e)),
_Act(Icons.delete_outline, 'Delete', () => _delete([e]), danger: true),
]);
}
// --- actions --------------------------------------------------------------
/// Runs a server action behind the thin progress bar under the breadcrumb.
Future<void> _run(Future<void> Function() job) async {
if (_busy) return;
setState(() => _busy = true);
try { try {
final results = await _api.search(_project, q.trim()); await job();
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) { } on ApiException catch (e) {
_toast(e.message, bad: true); if (mounted) toast(context, e.message, bad: true);
} finally {
if (mounted) setState(() => _busy = false);
} }
} }
void _menu() { void _note(String m, {required String action, required VoidCallback onTap}) {
showModalBottomSheet( if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(m),
backgroundColor: Ember.surfaceHi,
action: SnackBarAction(label: action, textColor: Ember.flame, onPressed: onTap),
));
}
Future<void> _download(List<FileEntry> es) => _downloadPaths(
[for (final e in es) e.path],
isDir: es.length == 1 && es.single.isDir,
);
Future<void> _downloadPaths(List<String> paths, {required bool isDir}) => FileActions.download(
context,
_api,
project: _project,
paths: paths,
fileName: FileActions.downloadName(paths, isDir: isDir, folderName: _folderName),
);
Future<void> _share(List<FileEntry> es) {
final paths = [for (final e in es) e.path];
return FileActions.shareRemote(
context,
_api,
project: _project,
paths: paths,
fileName: FileActions.downloadName(paths,
isDir: es.length == 1 && es.single.isDir, folderName: _folderName),
);
}
Future<void> _compress(List<String> paths) => _run(() async {
final out = await _api.zip(_project, paths);
await _list(_path, quiet: true);
_note('Created ${out.split('/').last}',
action: 'Share',
onTap: () => FileActions.shareRemote(context, _api,
project: _project, paths: [out], fileName: out.split('/').last));
});
Future<void> _extract(FileEntry e) async {
final name = await _ask('Extract ${e.name}', 'Folder name', stemOf(e.name),
'Always into a new folder — nothing that already exists is overwritten.');
if (name == null || name.isEmpty) return;
final parent = parentOf(e.path);
await _run(() async {
final r = await _api.unzip(_project, e.path, dest: parent.isEmpty ? name : '$parent/$name');
await _list(_path, quiet: true);
_note(
r.warnings == null
? 'Extracted to ${r.path.split('/').last}'
: 'Extracted to ${r.path.split('/').last} — some entries were skipped',
action: 'Open',
onTap: () => _list(r.path),
);
});
}
void _zipContents(FileEntry e) {
// Created once: the sheet rebuilds while it is dragged, and a future made in
// the builder would re-read the archive on every frame of the drag.
final info = _api.zipInfo(_project, e.path);
showModalBottomSheet<void>(
context: context, context: context,
backgroundColor: Ember.surface, backgroundColor: Ember.surface,
builder: (_) => SafeArea( isScrollControlled: true,
child: Column( builder: (sheet) => DraggableScrollableSheet(
mainAxisSize: MainAxisSize.min, expand: false,
children: [ initialChildSize: 0.7,
ListTile( maxChildSize: 0.95,
leading: const Icon(Icons.note_add_outlined), builder: (_, ctrl) => FutureBuilder<ZipInfo>(
title: const Text('New file'), future: info,
onTap: () { builder: (_, snap) {
Navigator.pop(context); final z = snap.data;
_create(dir: false); return Column(
}, children: [
), ListTile(
ListTile( leading: const Icon(Icons.folder_zip, color: Ember.flame),
leading: const Icon(Icons.create_new_folder_outlined), title: Text(e.name,
title: const Text('New folder'), maxLines: 1,
onTap: () { overflow: TextOverflow.ellipsis,
Navigator.pop(context); style: const TextStyle(fontWeight: FontWeight.w600)),
_create(dir: true); subtitle: Text(
}, z != null
), ? '${z.count} entries · ${humanSize(z.totalBytes)} unpacked'
ListTile( : snap.hasError
leading: const Icon(Icons.upload_file_outlined), ? '${snap.error}'
title: const Text('Upload from phone'), : 'Reading archive…',
onTap: () { style: const TextStyle(fontSize: 11, color: Ember.textFaint),
Navigator.pop(context); ),
_upload(); trailing: FilledButton.icon(
}, style: FilledButton.styleFrom(backgroundColor: Ember.flame),
), onPressed: () {
], Navigator.pop(sheet);
_extract(e);
},
icon: const Icon(Icons.unarchive_outlined, size: 16),
label: const Text('Extract'),
),
),
const Divider(height: 1),
Expanded(
child: z == null
? Center(child: snap.hasError ? null : const CircularProgressIndicator())
: ListView.builder(
controller: ctrl,
itemCount: z.entries.length + (z.truncated ? 1 : 0),
itemBuilder: (_, i) {
if (i == z.entries.length) {
return ListTile(
dense: true,
title: Text('…and ${z.count - z.entries.length} more',
style: const TextStyle(color: Ember.textFaint)),
);
}
final en = z.entries[i];
return ListTile(
dense: true,
leading: Icon(en.isDir ? Icons.folder : Icons.description_outlined,
size: 18, color: en.isDir ? Ember.flame : Ember.textDim),
title: Text(en.name, style: const TextStyle(fontSize: 12.5)),
trailing: en.isDir
? null
: Text(humanSize(en.size),
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
);
},
),
),
],
);
},
), ),
), ),
); );
} }
Future<void> _filePdf(FileEntry e) => PdfFlow.forFile(context, _api,
project: _project, path: e.path, name: e.name, onSavedToRepo: () => _list(_path, quiet: true));
Future<void> _folderPdf(String dir, String title) => PdfFlow.forFolder(context, _api,
project: _project, dir: dir, title: title, onSavedToRepo: () => _list(_path, quiet: true));
/// A new chat in this project with the path already typed, so asking about a
/// file is one tap instead of switching tabs and spelling out where it is.
Future<void> _askClaude(FileEntry e) async {
try {
final chat = await ref.read(chatsProvider.notifier).create(_project);
if (!mounted) return;
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => ChatScreen(
chatId: chat.id,
draft: e.isDir ? 'In `${e.path}/`: ' : 'About `${e.path}`: ',
),
));
} on ApiException catch (err) {
if (mounted) toast(context, err.message, bad: true);
}
}
Future<void> _rename(FileEntry e) async {
final name = await _ask('Rename', 'New name', e.name);
if (name == null || name.isEmpty || name == e.name) return;
final parent = parentOf(e.path);
await _run(() async {
await _api.rename(_project, e.path, parent.isEmpty ? name : '$parent/$name');
await _list(_path, quiet: true);
});
}
Future<void> _delete(List<FileEntry> es) async {
if (es.isEmpty) return;
final one = es.length == 1 ? es.single : null;
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text(one != null ? 'Delete ${one.name}?' : 'Delete ${es.length} items?'),
content: Text(es.any((e) => e.isDir)
? 'Folders go with everything in them. This cannot be undone.'
: '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;
await _run(() async {
for (final e in es) {
await _api.deletePath(_project, e.path);
}
await _list(_path, quiet: true);
if (mounted) toast(context, one != null ? 'Deleted ${one.name}' : 'Deleted ${es.length} items');
});
}
String _join(String name) => _path == '.' || _path.isEmpty ? name : '$_path/$name'; String _join(String name) => _path == '.' || _path.isEmpty ? name : '$_path/$name';
Future<void> _create({required bool dir}) async { Future<void> _create({required bool dir}) async {
final name = await _ask(dir ? 'New folder' : 'New file', 'Name'); final name = await _ask(dir ? 'New folder' : 'New file', 'Name');
if (name == null || name.isEmpty) return; if (name == null || name.isEmpty) return;
try { await _run(() async {
dir ? await _api.mkdir(_project, _join(name)) : await _api.createFile(_project, _join(name)); dir ? await _api.mkdir(_project, _join(name)) : await _api.createFile(_project, _join(name));
await _list(_path); await _list(_path, quiet: true);
} on ApiException catch (e) { });
_toast(e.message, bad: true);
}
} }
Future<void> _upload() async { Future<void> _upload() async {
final picked = await FilePicker.platform.pickFiles(withData: true, allowMultiple: true); final picked = await FilePicker.platform.pickFiles(withData: true, allowMultiple: true);
if (picked == null) return; if (picked == null) return;
for (final f in picked.files) { await _run(() async {
final bytes = f.bytes; var done = 0;
if (bytes == null) continue; for (final f in picked.files) {
try { final bytes = f.bytes;
await _api.uploadFile(_project, _join(f.name), bytes); if (bytes == null) continue;
} on ApiException catch (e) { try {
_toast('${f.name}: ${e.message}', bad: true); await _api.uploadFile(_project, _join(f.name), bytes);
done++;
} on ApiException catch (e) {
if (mounted) toast(context, '${f.name}: ${e.message}', bad: true);
}
} }
} await _list(_path, quiet: true);
await _list(_path); if (done > 0 && mounted) toast(context, 'Uploaded $done file${done == 1 ? '' : 's'}');
});
} }
Future<void> _entryMenu(FileEntry e) async { Future<void> _search() async {
showModalBottomSheet( final q = await _ask('Search files', 'Filename contains…');
context: context, if (q == null || q.isEmpty || !mounted) return;
backgroundColor: Ember.surface, try {
builder: (_) => SafeArea( final results = await _api.search(_project, q, _path);
child: Column( if (!mounted) return;
mainAxisSize: MainAxisSize.min, showModalBottomSheet<void>(
children: [ context: context,
Padding( backgroundColor: Ember.surface,
padding: const EdgeInsets.all(16), isScrollControlled: true,
child: Text(e.name, builder: (sheet) => DraggableScrollableSheet(
style: const TextStyle(fontWeight: FontWeight.w600), expand: false,
overflow: TextOverflow.ellipsis), initialChildSize: 0.7,
), builder: (_, ctrl) => results.isEmpty
ListTile( ? const Center(child: Text('Nothing matches', style: TextStyle(color: Ember.textDim)))
leading: const Icon(Icons.drive_file_rename_outline), : ListView.builder(
title: const Text('Rename'), controller: ctrl,
onTap: () async { itemCount: results.length,
Navigator.pop(context); itemBuilder: (_, i) => ListTile(
final name = await _ask('Rename', 'New name', e.name); leading: Icon(results[i].isDir ? Icons.folder : Icons.description_outlined,
if (name == null || name.isEmpty) return; size: 20, color: results[i].isDir ? Ember.flame : Ember.textDim),
final parent = e.path.contains('/') title: Text(results[i].name, style: const TextStyle(fontSize: 14)),
? e.path.substring(0, e.path.lastIndexOf('/')) subtitle: Text(results[i].path,
: ''; style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
try { onTap: () {
await _api.rename(_project, e.path, parent.isEmpty ? name : '$parent/$name'); Navigator.pop(sheet);
await _list(_path); _openEntry(results[i]);
} 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);
}
},
),
],
), ),
), );
); } on ApiException catch (e) {
if (mounted) toast(context, e.message, bad: true);
}
} }
Future<String?> _ask(String title, String hint, [String initial = '']) async { Future<String?> _ask(String title, String hint, [String initial = '', String? help]) async {
final ctl = TextEditingController(text: initial); final ctl = TextEditingController(text: initial);
return showDialog<String>( return showDialog<String>(
context: context, context: context,
builder: (c) => AlertDialog( builder: (c) => AlertDialog(
title: Text(title), title: Text(title),
content: TextField( content: Column(
controller: ctl, mainAxisSize: MainAxisSize.min,
autofocus: true, crossAxisAlignment: CrossAxisAlignment.start,
decoration: InputDecoration(hintText: hint), children: [
onSubmitted: (v) => Navigator.pop(c, v.trim()), TextField(
controller: ctl,
autofocus: true,
decoration: InputDecoration(hintText: hint),
onSubmitted: (v) => Navigator.pop(c, v.trim()),
),
if (help != null)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(help, style: const TextStyle(fontSize: 11.5, color: Ember.textFaint)),
),
],
), ),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(c), child: const Text('Cancel')), TextButton(onPressed: () => Navigator.pop(c), child: const Text('Cancel')),
...@@ -340,13 +679,39 @@ class _FilesScreenState extends ConsumerState<FilesScreen> { ...@@ -340,13 +679,39 @@ class _FilesScreenState extends ConsumerState<FilesScreen> {
), ),
); );
} }
}
void _toast(String m, {bool bad = false}) { class _Act {
if (!mounted) return; const _Act(this.icon, this.label, this.run, {this.danger = false});
ScaffoldMessenger.of(context).showSnackBar(SnackBar( final IconData icon;
content: Text(m), final String label;
backgroundColor: bad ? Ember.bad.withValues(alpha: 0.9) : Ember.surfaceHi, final VoidCallback run;
)); final bool danger;
}
class _MenuHeader extends StatelessWidget {
const _MenuHeader({required this.entry});
final FileEntry entry;
@override
Widget build(BuildContext context) {
final when = entry.modified == null ? '' : ' · changed ${timeago.format(entry.modified!)}';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(entry.isDir ? Icons.folder : Icons.description_outlined,
color: entry.isDir ? Ember.flame : Ember.textDim),
title: Text(entry.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text('${entry.isDir ? 'Folder' : humanSize(entry.size)}$when',
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
),
const Divider(height: 1),
],
);
} }
} }
...@@ -387,8 +752,7 @@ class _ProjectButton extends ConsumerWidget { ...@@ -387,8 +752,7 @@ class _ProjectButton extends ConsumerWidget {
: Text(projects[i].description, : Text(projects[i].description,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
fontSize: 11, color: Ember.textFaint)),
isThreeLine: projects[i].description.isNotEmpty, isThreeLine: projects[i].description.isNotEmpty,
trailing: projects[i].name == project trailing: projects[i].name == project
? const Icon(Icons.check, color: Ember.flame, size: 18) ? const Icon(Icons.check, color: Ember.flame, size: 18)
...@@ -472,10 +836,21 @@ class _Breadcrumb extends StatelessWidget { ...@@ -472,10 +836,21 @@ class _Breadcrumb extends StatelessWidget {
} }
class _EntryTile extends StatelessWidget { class _EntryTile extends StatelessWidget {
const _EntryTile({required this.entry, required this.onTap, required this.onLongPress}); const _EntryTile({
required this.entry,
required this.selecting,
required this.selected,
required this.onTap,
required this.onLongPress,
required this.onMenu,
});
final FileEntry entry; final FileEntry entry;
final bool selecting;
final bool selected;
final VoidCallback onTap; final VoidCallback onTap;
final VoidCallback onLongPress; final VoidCallback onLongPress;
final VoidCallback onMenu;
static const _icons = <String, IconData>{ static const _icons = <String, IconData>{
'dart': Icons.flutter_dash, 'dart': Icons.flutter_dash,
...@@ -486,31 +861,43 @@ class _EntryTile extends StatelessWidget { ...@@ -486,31 +861,43 @@ class _EntryTile extends StatelessWidget {
'yml': Icons.data_object, 'yml': Icons.data_object,
'png': Icons.image_outlined, 'png': Icons.image_outlined,
'jpg': Icons.image_outlined, 'jpg': Icons.image_outlined,
'jpeg': Icons.image_outlined,
'gif': Icons.image_outlined,
'webp': Icons.image_outlined,
'svg': Icons.image_outlined, 'svg': Icons.image_outlined,
'zip': Icons.folder_zip_outlined,
'pdf': Icons.picture_as_pdf_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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ext = entry.name.contains('.') ? entry.name.split('.').last.toLowerCase() : ''; final when = entry.modified == null ? null : timeago.format(entry.modified!);
final sub = entry.isDir ? when : [humanSize(entry.size), ?when].join(' · ');
return ListTile( return ListTile(
dense: true, dense: true,
leading: Icon( selected: selected,
entry.isDir ? Icons.folder : (_icons[ext] ?? Icons.description_outlined), selectedTileColor: Ember.flame.withValues(alpha: 0.10),
color: entry.isDir ? Ember.flame : Ember.textDim, leading: selecting
size: 21, ? Icon(selected ? Icons.check_circle : Icons.radio_button_unchecked,
), color: selected ? Ember.flame : Ember.textFaint, size: 22)
title: Text(entry.name, style: const TextStyle(fontSize: 14)), : Icon(
subtitle: entry.isDir ? null : Text(_size(entry.size), entry.isDir ? Icons.folder : (_icons[extOf(entry.name)] ?? Icons.description_outlined),
style: const TextStyle(fontSize: 11, color: Ember.textFaint)), color: entry.isDir ? Ember.flame : Ember.textDim,
trailing: entry.isDir size: 21,
? const Icon(Icons.chevron_right, size: 18, color: Ember.textFaint) ),
: null, title: Text(entry.name,
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14)),
subtitle: sub == null
? null
: Text(sub, style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
trailing: selecting
? null
: IconButton(
tooltip: 'Actions',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.more_vert, size: 19, color: Ember.textFaint),
onPressed: onMenu,
),
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
); );
......
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
import 'file_actions.dart';
import 'pdf_flow.dart';
/// An image from the repo, shown as an image — not as the "binary file" panel
/// it used to land on. Pinch to zoom; double-tap to zoom in and back out.
class ImageViewerScreen extends ConsumerStatefulWidget {
const ImageViewerScreen({
super.key,
required this.project,
required this.path,
required this.name,
});
final String project;
final String path;
final String name;
@override
ConsumerState<ImageViewerScreen> createState() => _ImageViewerScreenState();
}
class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
final _zoom = TransformationController();
Offset _tap = Offset.zero;
Uint8List? _bytes;
Object? _error;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_zoom.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() => _error = null);
try {
final b = await ref.read(apiProvider).download(widget.project, [widget.path], inline: true);
if (mounted) setState(() => _bytes = b);
} catch (e) {
if (mounted) setState(() => _error = e);
}
}
void _toggleZoom() {
if (_zoom.value.getMaxScaleOnAxis() > 1.01) {
_zoom.value = Matrix4.identity();
return;
}
const s = 2.5;
// Scale about the tapped point, so what you double-tapped stays under
// your finger instead of flying off to a corner.
_zoom.value = Matrix4.diagonal3Values(s, s, 1)
..setTranslationRaw((1 - s) * _tap.dx, (1 - s) * _tap.dy, 0);
}
@override
Widget build(BuildContext context) {
final bytes = _bytes;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
Text(bytes == null ? widget.path : '${widget.path} · ${humanSize(bytes.length)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 10, color: Ember.textFaint)),
],
),
actions: [
IconButton(
tooltip: 'Download',
icon: const Icon(Icons.download_outlined),
onPressed: bytes == null
? null
: () => FileActions.saveBytes(context, fileName: widget.name, bytes: bytes),
),
IconButton(
tooltip: 'Share',
icon: const Icon(Icons.ios_share),
onPressed: bytes == null
? null
: () async => FileActions.share(await FileActions.writeTemp(widget.name, bytes)),
),
IconButton(
tooltip: 'Export as PDF',
icon: const Icon(Icons.picture_as_pdf_outlined),
onPressed: () => PdfFlow.forFile(context, ref.read(apiProvider),
project: widget.project, path: widget.path, name: widget.name),
),
],
),
body: _error != null
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Text('$_error',
textAlign: TextAlign.center, style: const TextStyle(color: Ember.textDim)),
),
OutlinedButton(onPressed: _load, child: const Text('Retry')),
],
),
)
: bytes == null
? const Center(child: CircularProgressIndicator())
: GestureDetector(
onDoubleTapDown: (d) => _tap = d.localPosition,
onDoubleTap: _toggleZoom,
child: InteractiveViewer(
transformationController: _zoom,
minScale: 0.5,
maxScale: 10,
child: SizedBox.expand(
child: Image.memory(
bytes,
fit: BoxFit.contain,
gaplessPlayback: true,
errorBuilder: (_, __, ___) => const Center(
child: Text('This image format cannot be displayed.',
style: TextStyle(color: Ember.textDim)),
),
),
),
),
),
);
}
}
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:intl/intl.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:mime/mime.dart';
import 'package:printing/printing.dart';
import 'package:re_highlight/languages/all.dart';
import 'package:re_highlight/re_highlight.dart';
import 'package:re_highlight/styles/github.dart';
import '../../core/api/models.dart';
/// Loads a repo image a markdown document references (`![](docs/shot.png)`).
/// Null when it cannot be had; the PDF then shows the alt text.
typedef ImageLoader = Future<Uint8List?> Function(String src);
/// PDFs are made on the phone. The server has no renderer — no Chromium, no
/// pandoc, no LibreOffice — and one would add hundreds of MB to its image.
///
/// A document is built as static HTML here (markdown through `markdown`, code
/// through re_highlight, repo images inlined as data URIs), any mermaid is
/// rendered to SVG in a headless WebView, and Android's print framework turns
/// the result into a PDF. The mermaid pass exists because that print WebView
/// runs with JavaScript off: a diagram handed to it directly prints as source.
class PdfExport {
static Future<Uint8List> markdown({
required String title,
required String source,
String? subtitle,
ImageLoader? images,
}) async {
final ctx = _Ctx(images);
final body = await _markdownHtml(source, ctx);
return _print(_page(title, subtitle, '<article>$body</article>'), mermaid: ctx.mermaid);
}
/// Source code as a numbered, highlighted listing. Highlighting a long file
/// runs off the UI isolate so the screen does not freeze while it works.
static Future<Uint8List> code({
required String title,
required String source,
String? language,
String? subtitle,
}) async {
final body = await compute(_listingIsolate, (source, language ?? ''));
return _print(_page(title, subtitle, body));
}
static Future<Uint8List> image({
required String title,
required Uint8List bytes,
required String mime,
String? subtitle,
}) =>
_print(_page(title, subtitle,
'<figure class="image"><img src="data:$mime;base64,${base64Encode(bytes)}"></figure>'));
/// Several documents as one PDF behind a contents list — "export this folder".
static Future<Uint8List> documents({
required String title,
required List<({String path, String source})> docs,
String? subtitle,
ImageLoader Function(String docPath)? images,
}) async {
var mermaid = false;
final toc = StringBuffer('<nav class="toc"><div class="toc-h">Contents</div><ol>');
final body = StringBuffer();
for (var i = 0; i < docs.length; i++) {
final d = docs[i];
final ctx = _Ctx(images?.call(d.path));
toc.write('<li><a href="#d$i" dir="auto">${_esc(d.path)}</a></li>');
final html = isMarkdown(d.path)
? await _markdownHtml(d.source, ctx)
: await compute(_listingIsolate, (d.source, languageFor(d.path)));
body.write('<section class="doc" id="d$i"><div class="doc-path" dir="auto">${_esc(d.path)}</div>$html</section>');
mermaid |= ctx.mermaid;
}
toc.write('</ol></nav>');
return _print(_page(title, subtitle, '$toc$body'), mermaid: mermaid);
}
/// A whole conversation: prompts, answers, and the agent's tool log.
static Future<Uint8List> chat(Chat c) async {
final ctx = _Ctx(null);
final when = DateFormat('d MMM y, HH:mm');
final b = StringBuffer();
for (final m in c.messages) {
final who = m.isUser
? 'You'
: [
'Claude',
if ((m.model ?? '').isNotEmpty) m.model!,
if ((m.effort ?? '').isNotEmpty) m.effort!,
if (m.status == RunStatus.failed) 'failed',
if (m.wasPlan) 'plan',
].join(' · ');
b.write('<section class="msg ${m.isUser ? 'user' : 'bot'}">'
'<div class="who">${_esc(who)}<span>${_esc(when.format(m.createdAt.toLocal()))}</span></div>');
b.write(m.isUser ? await _markdownHtml(m.content, ctx) : await _agentHtml(m.content, ctx));
if (m.attachments.isNotEmpty) {
b.write('<div class="att">Attached: ${_esc(m.attachments.join(', '))}</div>');
}
b.write('</section>');
}
return _print(
_page(c.title, '${c.project} · ${c.messages.length} messages', b.toString()),
mermaid: ctx.mermaid,
);
}
static bool isMarkdown(String path) {
final p = path.toLowerCase();
return p.endsWith('.md') || p.endsWith('.markdown') || p.endsWith('.mdx');
}
/// A highlight.js language id for a file name, or '' for plain text.
static String languageFor(String path) {
final name = path.split('/').last.toLowerCase();
if (name == 'dockerfile' || name.startsWith('dockerfile.')) return 'dockerfile';
if (name == 'makefile') return 'makefile';
final ext = name.contains('.') ? name.split('.').last : '';
final id = _alias[ext] ?? ext;
return builtinAllLanguages.containsKey(id) ? id : '';
}
// --- building HTML --------------------------------------------------------
static const _autoDir = {
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'ul', 'ol', 'blockquote', 'td', 'th', 'dt', 'dd',
};
static Future<String> _markdownHtml(String source, _Ctx ctx) async {
final nodes = md.Document(extensionSet: md.ExtensionSet.gitHubWeb).parse(source);
await _rewrite(nodes, ctx);
return md.renderToHtml(nodes);
}
/// Walks the markdown tree before it is rendered: fenced mermaid becomes a
/// diagram placeholder, other fences get highlighted, repo images are inlined,
/// and text blocks take `dir="auto"` so an Arabic paragraph sets right-to-left
/// on its own while an English one beside it stays left-to-right.
static Future<void> _rewrite(List<md.Node> nodes, _Ctx ctx) async {
for (var i = 0; i < nodes.length; i++) {
final n = nodes[i];
if (n is! md.Element) continue;
if (n.tag == 'pre') {
final code = n.children?.whereType<md.Element>().where((e) => e.tag == 'code').firstOrNull;
if (code != null) {
final lang = (code.attributes['class'] ?? '').replaceFirst('language-', '').trim().toLowerCase();
final raw = _unesc(code.textContent);
if (lang == 'mermaid') {
ctx.mermaid = true;
nodes[i] = md.Element('div', [md.Text(_esc(raw))])..attributes['class'] = 'mermaid';
} else {
nodes[i] = md.Element('pre', [
md.Element('code', [md.Text(_highlight(raw, lang))])..attributes['class'] = 'hljs',
]);
}
continue;
}
}
if (n.tag == 'img') {
await _inline(n, ctx);
continue;
}
if (_autoDir.contains(n.tag)) n.attributes['dir'] = 'auto';
final kids = n.children;
if (kids != null) await _rewrite(kids, ctx);
}
}
static Future<void> _inline(md.Element img, _Ctx ctx) async {
final loader = ctx.images;
final src = _unesc(img.attributes['src'] ?? '');
if (loader == null || src.isEmpty || ctx.inlined >= 40) return;
// Remote images load in the print WebView itself; only repo-relative
// paths need fetching through the API.
if (RegExp(r'^[a-z][a-z0-9+.-]*:', caseSensitive: false).hasMatch(src)) return;
try {
final path = Uri.decodeFull(src.split('#').first.split('?').first);
final bytes = await loader(path);
if (bytes == null || bytes.isEmpty || bytes.length > 8 * 1024 * 1024) return;
final mime = lookupMimeType(path, headerBytes: bytes.take(16).toList()) ?? 'image/png';
img.attributes['src'] = 'data:$mime;base64,${base64Encode(bytes)}';
ctx.inlined++;
} catch (_) {/* the alt text stands in */}
}
// The agent's transcript mixes prose with a tool log (▶ ⚙ → ✗ · ■ lines).
// Through markdown alone the log would collapse into one run-on paragraph.
static const _logMarks = {'▶', '⚙', '→', '✗', '·', '■', '✓'};
static Future<String> _agentHtml(String text, _Ctx ctx) async {
final b = StringBuffer();
final prose = <String>[];
final log = <String>[];
Future<void> flushProse() async {
if (prose.isEmpty) return;
b.write(await _markdownHtml(prose.join('\n'), ctx));
prose.clear();
}
void flushLog() {
if (log.isEmpty) return;
b.write('<pre class="log">${_esc(log.join('\n'))}</pre>');
log.clear();
}
for (final line in text.split('\n')) {
final t = line.trimLeft();
if (t.isNotEmpty && _logMarks.contains(t[0])) {
await flushProse();
log.add(line);
} else {
flushLog();
prose.add(line);
}
}
await flushProse();
flushLog();
return b.toString();
}
static final _hl = Highlight()..registerLanguages(builtinAllLanguages);
static const _alias = {
'js': 'javascript', 'mjs': 'javascript', 'cjs': 'javascript', 'jsx': 'javascript',
'ts': 'typescript', 'tsx': 'typescript', 'py': 'python', 'sh': 'bash', 'zsh': 'bash',
'shell': 'bash', 'console': 'bash', 'yml': 'yaml', 'html': 'xml', 'htm': 'xml',
'vue': 'xml', 'svg': 'xml', 'md': 'markdown', 'mdx': 'markdown', 'kt': 'kotlin',
'kts': 'kotlin', 'rb': 'ruby', 'rs': 'rust', 'cs': 'csharp', 'c++': 'cpp', 'hpp': 'cpp',
'cc': 'cpp', 'h': 'c', 'blade': 'php', 'env': 'ini', 'toml': 'ini', 'conf': 'ini',
'cfg': 'ini', 'jsonc': 'json', 'lock': 'json', 'docker': 'dockerfile', 'make': 'makefile',
'ps1': 'powershell', 'txt': 'plaintext', 'text': 'plaintext', 'log': 'plaintext',
};
static String _highlight(String code, String lang) {
final id = _alias[lang] ?? lang;
if (id.isEmpty || id == 'plaintext' || !builtinAllLanguages.containsKey(id)) return _esc(code);
try {
return _hl.highlight(code: code, language: id).toHtml();
} catch (_) {
return _esc(code);
}
}
static String _listingIsolate((String, String) a) => _listing(a.$1, a.$2);
@visibleForTesting
static String listingHtml(String source, String lang) => _listing(source, lang);
/// One row per source line, each with its number. The highlighter's spans can
/// run across lines (a block comment, a multi-line string), so lines are cut
/// by closing every open span at the newline and reopening it on the next —
/// that keeps the markup balanced and lets long lines wrap without the line
/// numbers drifting out of step.
static String _listing(String source, String lang) {
final html = _highlight(source.replaceAll('\r\n', '\n'), lang.toLowerCase());
final lines = _splitLines(html);
if (lines.length > 1 && _stripTags(lines.last).isEmpty) lines.removeLast();
final width = lines.length.toString().length;
final b = StringBuffer('<div class="listing hljs" style="--w:${width}ch">');
for (var i = 0; i < lines.length; i++) {
b.write('<div class="l"><span class="n">${i + 1}</span><span class="c">'
'${_stripTags(lines[i]).isEmpty ? '&#8203;${lines[i]}' : lines[i]}</span></div>');
}
b.write('</div>');
return b.toString();
}
static List<String> _splitLines(String html) {
final out = <String>[];
final open = <String>[];
final buf = StringBuffer();
void text(String s) {
final parts = s.split('\n');
for (var k = 0; k < parts.length; k++) {
if (k > 0) {
buf.write('</span>' * open.length);
out.add(buf.toString());
buf
..clear()
..write(open.join());
}
buf.write(parts[k]);
}
}
var at = 0;
for (final m in RegExp(r'<span[^>]*>|</span>').allMatches(html)) {
text(html.substring(at, m.start));
final tag = m.group(0)!;
if (tag == '</span>') {
if (open.isNotEmpty) open.removeLast();
} else {
open.add(tag);
}
buf.write(tag);
at = m.end;
}
text(html.substring(at));
out.add(buf.toString());
return out;
}
static String _stripTags(String s) => s.replaceAll(RegExp(r'<[^>]*>'), '');
static String _esc(String s) =>
s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
static String _unesc(String s) => s
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot;', '"')
.replaceAll('&#39;', "'")
.replaceAll('&#x27;', "'")
.replaceAll('&amp;', '&');
static String _page(String title, String? subtitle, String body) {
final stamp = DateFormat('d MMM y, HH:mm').format(DateTime.now());
final sub = [if (subtitle != null && subtitle.isNotEmpty) subtitle, 'Son of Anton · $stamp'].join(' · ');
return '<!doctype html><html><head><meta charset="utf-8">'
'<meta name="viewport" content="width=device-width, initial-scale=1">'
'<title>${_esc(title)}</title><style>$_css$_hlCss</style></head><body>'
'<header class="head"><div class="t" dir="auto">${_esc(title)}</div>'
'<div class="s" dir="auto">${_esc(sub)}</div></header>'
'$body</body></html>';
}
// --- printing -------------------------------------------------------------
static Future<Uint8List> _print(String html, {bool mermaid = false}) async {
final ready = mermaid ? await _renderMermaid(html) : html;
// Deprecated upstream in favour of composing PDFs widget by widget, which
// cannot lay out markdown tables, right-to-left Arabic or SVG diagrams the
// way a browser engine does. On Android this is still the WebView print
// path, and it is the only one that renders what the editor shows.
// ignore: deprecated_member_use
return Printing.convertHtml(html: ready);
}
static Future<String> _renderMermaid(String html) async {
final page = html.replaceFirst('</head>', '$_mermaidJs</head>');
final done = Completer<String>();
final view = HeadlessInAppWebView(
// mermaid measures text to lay a diagram out; a zero-sized view would
// measure nothing and draw everything on top of itself.
initialSize: const Size(900, 1400),
initialData: InAppWebViewInitialData(data: page, mimeType: 'text/html', encoding: 'utf-8'),
initialSettings: InAppWebViewSettings(javaScriptEnabled: true),
onWebViewCreated: (c) => c.addJavaScriptHandler(
handlerName: 'rendered',
callback: (args) {
if (!done.isCompleted) done.complete(args.isEmpty ? html : '${args.first}');
return null;
},
),
);
try {
await view.run();
// Offline, or a CDN that never answers: diagrams stay as their source,
// styled as such, rather than the export hanging.
return await done.future.timeout(const Duration(seconds: 30), onTimeout: () => html);
} finally {
await view.dispose();
}
}
static const _mermaidJs = r'''
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>
(function () {
var sent = false;
function send() {
if (sent) return; sent = true;
document.querySelectorAll('script').forEach(function (s) { s.remove(); });
window.flutter_inappwebview.callHandler('rendered', '<!doctype html>' + document.documentElement.outerHTML);
}
function go() {
if (!window.mermaid) return send();
try {
mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'strict',
flowchart: { htmlLabels: false } });
mermaid.run({ querySelector: '.mermaid', suppressErrors: true }).then(send, send);
} catch (e) { send(); }
}
function ready() {
if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) go();
else window.addEventListener('flutterInAppWebViewPlatformReady', go);
}
if (document.readyState === 'complete') ready(); else window.addEventListener('load', ready);
})();
</script>''';
// Paper, not screen: light, GitHub-like, logical properties so RTL blocks
// mirror correctly, and page-break rules that keep headings with their text.
static const _css = '''
@page{size:A4;margin:16mm 14mm}
html{-webkit-print-color-adjust:exact;print-color-adjust:exact}
body{margin:0;font-family:Roboto,"Noto Sans","Noto Naskh Arabic","Noto Sans Arabic",sans-serif;font-size:11pt;line-height:1.55;color:#1f2328;overflow-wrap:break-word}
.head{border-bottom:2px solid #FF4D1C;padding-bottom:6px;margin-bottom:18px}
.head .t{font-size:13pt;font-weight:700}
.head .s{font-size:8pt;color:#8c959f;margin-top:2px}
h1,h2,h3,h4,h5,h6{line-height:1.25;margin:1.2em 0 .5em;page-break-after:avoid;break-after:avoid}
h1{font-size:20pt;border-bottom:1px solid #d0d7de;padding-bottom:.2em}
h2{font-size:16pt;border-bottom:1px solid #d0d7de;padding-bottom:.2em}
h3{font-size:13pt}h4{font-size:11.5pt}
p,li{orphans:3;widows:3}
a{color:#0969da;text-decoration:none}
code{font-family:"Roboto Mono","Droid Sans Mono",monospace;font-size:9pt;background:#f6f8fa;border-radius:4px;padding:.1em .3em}
pre{background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:10px 12px;white-space:pre-wrap;word-break:break-word;direction:ltr;text-align:left}
pre code{background:none;padding:0;font-size:8.5pt;line-height:1.45}
pre.log{font-family:"Roboto Mono","Droid Sans Mono",monospace;font-size:8pt;line-height:1.45;color:#57606a;background:#fafbfc}
blockquote{margin:0 0 1em;padding:0 1em;color:#57606a;border-inline-start:3px solid #FF4D1C}
table{border-collapse:collapse;width:100%;margin:.8em 0;font-size:10pt}
th,td{border:1px solid #d0d7de;padding:5px 9px;text-align:start;vertical-align:top}
th{background:#f6f8fa}
tr{page-break-inside:avoid;break-inside:avoid}
img{max-width:100%}
hr{border:0;border-top:1px solid #d0d7de;margin:1.5em 0}
ul,ol{padding-inline-start:1.6em}
.mermaid{text-align:center;margin:1em 0;page-break-inside:avoid;break-inside:avoid}
.mermaid:not([data-processed]){white-space:pre-wrap;text-align:left;direction:ltr;font-family:monospace;font-size:8.5pt;background:#fff8e6;border:1px dashed #d4a72c;padding:10px}
.mermaid svg{max-width:100%;height:auto}
.image{margin:0;text-align:center}.image img{max-height:240mm}
.listing{font-family:"Roboto Mono","Droid Sans Mono",monospace;font-size:8pt;line-height:1.5;border:1px solid #d0d7de;border-radius:6px;background:#fbfcfd;direction:ltr;text-align:left}
.listing .l{display:flex;page-break-inside:avoid;break-inside:avoid}
.listing .n{flex:none;width:calc(var(--w) + 1.2em);padding-right:.7em;text-align:right;color:#8c959f;background:#f6f8fa;border-right:1px solid #d0d7de}
.listing .c{flex:1;min-width:0;padding-left:.8em;white-space:pre-wrap;word-break:break-all}
.toc{page-break-after:always;break-after:page}
.toc-h{font-size:14pt;font-weight:700;margin-bottom:.6em}
.toc li{margin:.2em 0;font-size:10pt}
.doc{page-break-before:always;break-before:page}
.toc+.doc{page-break-before:avoid;break-before:avoid}
.doc-path{font-family:"Roboto Mono",monospace;font-size:8.5pt;color:#57606a;background:#f6f8fa;border-radius:4px;padding:4px 8px;margin-bottom:12px}
.msg{margin:0 0 14px;padding:10px 12px;border:1px solid #d0d7de;border-radius:8px;page-break-inside:auto}
.msg.user{background:#fff4ef;border-color:#ffc9b3}
.msg .who{font-size:8.5pt;font-weight:700;color:#57606a;margin-bottom:6px;display:flex;justify-content:space-between;gap:12px}
.msg .who span{font-weight:400;color:#8c959f}
.msg>:last-child{margin-bottom:0}
.att{font-size:8.5pt;color:#57606a;margin-top:6px}
''';
/// highlight.js classes from re_highlight's GitHub-light theme, so the PDF
/// highlights code with the same palette the highlighter was built for.
/// A tiered scope key ("title.class_") is the class pair `.hljs-title.class_`.
static final String _hlCss = () {
String hex(Color c) => '#${(c.toARGB32() & 0xFFFFFF).toRadixString(16).padLeft(6, '0')}';
final b = StringBuffer('.hljs{color:#24292e}');
githubTheme.forEach((key, s) {
if (key == 'root') return;
final parts = key.split('.');
final sel = ['.hljs-${parts.first}', ...parts.skip(1).map((p) => '.$p')].join();
final rules = [
if (s.color != null) 'color:${hex(s.color!)}',
if (s.fontWeight == FontWeight.bold) 'font-weight:600',
if (s.fontStyle == FontStyle.italic) 'font-style:italic',
if (s.decoration == TextDecoration.underline) 'text-decoration:underline',
];
if (rules.isNotEmpty) b.write('$sel{${rules.join(';')}}');
});
return b.toString();
}();
}
class _Ctx {
_Ctx(this.images);
final ImageLoader? images;
bool mermaid = false;
int inlined = 0;
}
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:mime/mime.dart';
import '../../core/api/son_api.dart';
import '../../core/theme.dart';
import 'file_actions.dart';
import 'pdf_export.dart';
const imageExtensions = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'};
String extOf(String name) => name.contains('.') ? name.split('.').last.toLowerCase() : '';
bool isImageName(String name) => imageExtensions.contains(extOf(name));
bool isZipName(String name) => extOf(name) == 'zip';
String stemOf(String name) {
final i = name.lastIndexOf('.');
return i > 0 ? name.substring(0, i) : name;
}
String parentOf(String path) => path.contains('/') ? path.substring(0, path.lastIndexOf('/')) : '';
/// A chat title or folder name made safe to use as a file name.
String safeFileName(String s) {
final t = s.replaceAll(RegExp(r'[\\/:*?"<>|\n\r\t]+'), ' ').replaceAll(RegExp(r'\s+'), ' ').trim();
return t.isEmpty ? 'export' : (t.length > 80 ? t.substring(0, 80).trim() : t);
}
/// Resolves `img.png`, `./shots/x.png` or `../assets/x.png` against the folder
/// the document lives in. A path that climbs out of the project is refused
/// here, before any request, and the server would refuse it anyway.
ImageLoader repoImages(SonApi api, String project, String docPath) => (src) async {
final base = src.startsWith('/') ? '' : parentOf(docPath);
final segs = <String>[];
for (final s in '$base/${src.startsWith('/') ? src.substring(1) : src}'.split('/')) {
if (s.isEmpty || s == '.') continue;
if (s == '..') {
if (segs.isEmpty) return null;
segs.removeLast();
} else {
segs.add(s);
}
}
if (segs.isEmpty) return null;
try {
return await api.download(project, [segs.join('/')], inline: true);
} catch (_) {
return null;
}
};
/// The UI around a PDF export: a dialog while it is made, then a sheet to save
/// it to the phone, share it, or put it into the repo beside its source.
class PdfFlow {
static Future<void> run(
BuildContext context, {
required String fileName,
required Future<Uint8List> Function(void Function(String) status) build,
Future<String> Function(Uint8List pdf)? saveToRepo,
VoidCallback? onSavedToRepo,
}) async {
final status = ValueNotifier<String>('Making $fileName…');
final nav = Navigator.of(context, rootNavigator: true);
var shown = true;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => PopScope(
canPop: false,
child: AlertDialog(
content: Row(
children: [
const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.4, color: Ember.flame),
),
const SizedBox(width: 16),
Expanded(
child: ValueListenableBuilder<String>(
valueListenable: status,
builder: (_, s, __) => Text(s, style: const TextStyle(fontSize: 13)),
),
),
],
),
),
),
).whenComplete(() => shown = false);
Uint8List? pdf;
try {
pdf = await build((s) => status.value = s);
} catch (e) {
if (context.mounted) {
toast(context, 'Could not make the PDF: ${e is ApiException ? e.message : e}', bad: true);
}
} finally {
if (shown) nav.pop();
}
if (pdf == null || !context.mounted) return;
final bytes = pdf;
await showModalBottomSheet<void>(
context: context,
backgroundColor: Ember.surface,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.picture_as_pdf, color: Ember.flame),
title: Text(fileName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text('PDF · ${humanSize(bytes.length)}',
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.download_outlined),
title: const Text('Save to phone'),
onTap: () {
Navigator.pop(sheet);
FileActions.saveBytes(context, fileName: fileName, bytes: bytes);
},
),
ListTile(
leading: const Icon(Icons.ios_share),
title: const Text('Share'),
onTap: () async {
Navigator.pop(sheet);
await FileActions.share(await FileActions.writeTemp(fileName, bytes));
},
),
if (saveToRepo != null)
ListTile(
leading: const Icon(Icons.drive_folder_upload_outlined),
title: const Text('Save into the repo'),
subtitle: const Text('Beside the original — never over an existing file',
style: TextStyle(fontSize: 11, color: Ember.textFaint)),
onTap: () async {
Navigator.pop(sheet);
try {
final p = await saveToRepo(bytes);
if (context.mounted) toast(context, 'Saved $p');
onSavedToRepo?.call();
} on ApiException catch (e) {
if (context.mounted) toast(context, e.message, bad: true);
}
},
),
],
),
),
);
}
/// Writes into [dir] under a name nothing there already has: "x.pdf", then
/// "x (2).pdf". Upload overwrites, so the check has to happen here.
static Future<String> saveInto(
SonApi api, String project, String dir, String fileName, Uint8List bytes) async {
final d = dir.isEmpty ? '.' : dir;
final taken = {for (final e in (await api.list(project, d)).entries) e.name};
final stem = stemOf(fileName);
final ext = extOf(fileName);
var name = fileName;
for (var i = 2; taken.contains(name); i++) {
name = '$stem ($i).$ext';
}
final path = d == '.' ? name : '$d/$name';
await api.uploadFile(project, path, bytes);
return path;
}
/// One file. [source] is the editor's current text when there is one, so
/// unsaved edits are what gets exported.
static Future<void> forFile(
BuildContext context,
SonApi api, {
required String project,
required String path,
required String name,
String? source,
VoidCallback? onSavedToRepo,
}) {
final pdfName = '${stemOf(name)}.pdf';
final subtitle = '$project/$path';
return run(
context,
fileName: pdfName,
onSavedToRepo: onSavedToRepo,
saveToRepo: (b) => saveInto(api, project, parentOf(path), pdfName, b),
build: (status) async {
if (isImageName(name)) {
final bytes = await api.download(project, [path], inline: true);
return PdfExport.image(
title: name, bytes: bytes, mime: lookupMimeType(name) ?? 'image/png', subtitle: subtitle);
}
// Through /download, not /read: /read stops at 1 MB, and a PDF of
// the first megabyte of a file would look complete.
final text = source ??
utf8.decode(await api.download(project, [path], inline: true), allowMalformed: true);
if (PdfExport.isMarkdown(name)) {
status('Rendering $name…');
return PdfExport.markdown(
title: name, source: text, subtitle: subtitle, images: repoImages(api, project, path));
}
return PdfExport.code(
title: name, source: text, language: PdfExport.languageFor(name), subtitle: subtitle);
},
);
}
static const maxDocs = 80;
/// Every markdown document under a folder as one PDF, READMEs first, behind a
/// contents list.
static Future<void> forFolder(
BuildContext context,
SonApi api, {
required String project,
required String dir,
required String title,
VoidCallback? onSavedToRepo,
}) {
final pdfName = '${safeFileName(title)}.pdf';
final d = dir.isEmpty ? '.' : dir;
return run(
context,
fileName: pdfName,
onSavedToRepo: onSavedToRepo,
saveToRepo: (b) => saveInto(api, project, d, pdfName, b),
build: (status) async {
status('Finding documents…');
bool readme(String p) => p.split('/').last.toLowerCase().startsWith('readme');
final found = (await api.search(project, '.md', d))
.where((e) => !e.isDir && PdfExport.isMarkdown(e.name))
.toList()
..sort((a, b) {
final da = a.path.split('/').length, db = b.path.split('/').length;
if (da != db) return da.compareTo(db);
if (readme(a.path) != readme(b.path)) return readme(a.path) ? -1 : 1;
return a.path.toLowerCase().compareTo(b.path.toLowerCase());
});
if (found.isEmpty) throw ApiException('No markdown documents in this folder.');
final picked = found.take(maxDocs).toList();
final docs = <({String path, String source})>[];
for (var i = 0; i < picked.length; i++) {
status('Reading ${i + 1} of ${picked.length}…');
final b = await api.download(project, [picked[i].path], inline: true);
docs.add((path: picked[i].path, source: utf8.decode(b, allowMalformed: true)));
}
status('Rendering ${docs.length} documents…');
return PdfExport.documents(
title: title,
docs: docs,
subtitle: [
d == '.' ? project : '$project/$d',
if (found.length > maxDocs) 'first $maxDocs of ${found.length}',
].join(' · '),
images: (p) => repoImages(api, project, p),
);
},
);
}
}
...@@ -60,6 +60,79 @@ class _RepoCard extends ConsumerStatefulWidget { ...@@ -60,6 +60,79 @@ class _RepoCard extends ConsumerStatefulWidget {
class _RepoCardState extends ConsumerState<_RepoCard> { class _RepoCardState extends ConsumerState<_RepoCard> {
bool _busy = false; bool _busy = false;
bool _open = false;
/// Changed files, each tappable for its own diff — the whole-repo diff was
/// the only view, and finding one file in it meant scrolling through all.
Widget _changedFiles(RepoStatus r) {
final all = <(String, bool)>[
for (final f in r.tracked) (_clean(f), false),
for (final f in r.untracked) (_clean(f), true),
];
if (all.isEmpty) return const SizedBox.shrink();
final shown = _open ? all.take(60).toList() : const <(String, bool)>[];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
InkWell(
borderRadius: BorderRadius.circular(6),
onTap: () => setState(() => _open = !_open),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Icon(_open ? Icons.expand_less : Icons.expand_more, size: 16, color: Ember.textDim),
const SizedBox(width: 4),
Text(
_open
? 'Hide changed files'
: 'Show ${all.length} changed file${all.length == 1 ? '' : 's'}',
style: const TextStyle(fontSize: 12, color: Ember.textDim),
),
],
),
),
),
for (final (f, untracked) in shown)
InkWell(
onTap: () => _showDiff(file: f, untracked: untracked),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
SizedBox(
width: 18,
child: Text(
untracked ? 'U' : (r.modified.map(_clean).contains(f) ? 'M' : 'S'),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: untracked ? Ember.ok : Ember.amber),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(f,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, fontFamilyFallback: Ember.mono)),
),
const Icon(Icons.chevron_right, size: 16, color: Ember.textFaint),
],
),
),
),
if (_open && all.length > shown.length)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text('…and ${all.length - shown.length} more — Diff shows everything',
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
),
],
);
}
SonApi get _api => ref.read(apiProvider); SonApi get _api => ref.read(apiProvider);
...@@ -102,23 +175,89 @@ class _RepoCardState extends ConsumerState<_RepoCard> { ...@@ -102,23 +175,89 @@ class _RepoCardState extends ConsumerState<_RepoCard> {
await _run(() => _api.gitCommit(widget.repo.name, msg), 'Committed'); await _run(() => _api.gitCommit(widget.repo.name, msg), 'Committed');
} }
Future<void> _showDiff() async { /// Porcelain quotes paths with spaces and writes renames as "old -> new";
/// git wants the plain, current path back.
static String _clean(String f) {
final p = f.contains(' -> ') ? f.split(' -> ').last : f;
return p.length > 1 && p.startsWith('"') && p.endsWith('"') ? p.substring(1, p.length - 1) : p;
}
static String _stat(String diff) {
var add = 0, del = 0;
for (final l in diff.split('\n')) {
if (l.startsWith('+') && !l.startsWith('+++')) {
add++;
} else if (l.startsWith('-') && !l.startsWith('---')) {
del++;
}
}
return '+$add −$del';
}
Future<void> _discard(String file) async {
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Discard changes?'),
content: Text('$file goes back to its last committed state. This cannot be undone.'),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(c, true),
child: const Text('Discard', style: TextStyle(color: Ember.bad)),
),
],
),
);
if (ok == true) await _run(() => _api.gitDiscard(widget.repo.name, file), 'Discarded $file');
}
/// The repo's whole diff, or one file's. An untracked file has no diff yet,
/// so it is shown as what it is: every line new.
Future<void> _showDiff({String? file, bool untracked = false}) async {
final repo = widget.repo;
try { try {
final diff = await _api.gitDiff(widget.repo.name); final String diff;
if (untracked && file != null) {
final f = await _api.read(repo.name, file);
diff = f.isBinary
? ''
: f.content.split('\n').map((l) => '+$l').join('\n');
} else {
diff = await _api.gitDiff(repo.name, file);
}
if (!mounted) return; if (!mounted) return;
final canDiscard = file != null && repo.modified.map(_clean).contains(file);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: Ember.surface, backgroundColor: Ember.surface,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => DraggableScrollableSheet( builder: (sheet) => DraggableScrollableSheet(
expand: false, expand: false,
initialChildSize: 0.8, initialChildSize: 0.8,
builder: (_, ctrl) => Column( builder: (_, ctrl) => Column(
children: [ children: [
const SizedBox(height: 14), const SizedBox(height: 6),
Text('${widget.repo.name} · diff', ListTile(
style: const TextStyle(fontWeight: FontWeight.w600)), dense: true,
const SizedBox(height: 10), title: Text(file ?? '${repo.name} · all changes',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(untracked ? 'new file, not tracked yet' : _stat(diff),
style: const TextStyle(fontSize: 11, color: Ember.textFaint)),
trailing: canDiscard
? TextButton.icon(
onPressed: () {
Navigator.pop(sheet);
_discard(file);
},
icon: const Icon(Icons.undo, size: 16, color: Ember.bad),
label: const Text('Discard', style: TextStyle(color: Ember.bad)),
)
: null,
),
const Divider(height: 1),
Expanded( Expanded(
child: diff.trim().isEmpty child: diff.trim().isEmpty
? const Center( ? const Center(
...@@ -195,13 +334,14 @@ class _RepoCardState extends ConsumerState<_RepoCard> { ...@@ -195,13 +334,14 @@ class _RepoCardState extends ConsumerState<_RepoCard> {
_tag(Icons.check, 'clean', Ember.ok), _tag(Icons.check, 'clean', Ember.ok),
], ],
), ),
if (r.dirty) _changedFiles(r),
const SizedBox(height: 10), const SizedBox(height: 10),
if (_busy) if (_busy)
const LinearProgressIndicator(minHeight: 2, color: Ember.flame) const LinearProgressIndicator(minHeight: 2, color: Ember.flame)
else else
Row( Row(
children: [ children: [
_act(Icons.difference_outlined, 'Diff', _showDiff), _act(Icons.difference_outlined, 'Diff', () => _showDiff()),
_act(Icons.download_outlined, 'Pull', _act(Icons.download_outlined, 'Pull',
() => _run(() => _api.gitPull(r.name), 'Pulled')), () => _run(() => _api.gitPull(r.name), 'Pulled')),
_act(Icons.check_circle_outline, 'Commit', r.dirty ? _commit : null), _act(Icons.check_circle_outline, 'Commit', r.dirty ? _commit : null),
......
...@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; ...@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/models.dart'; import '../../core/api/models.dart';
import '../../core/lock.dart'; import '../../core/lock.dart';
import '../../core/prefs.dart';
import '../../core/providers.dart'; import '../../core/providers.dart';
import '../../core/watcher.dart'; import '../../core/watcher.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
...@@ -19,6 +20,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ...@@ -19,6 +20,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
bool _lockOn = false; bool _lockOn = false;
bool _lockAvailable = false; bool _lockAvailable = false;
bool _watchOn = false; bool _watchOn = false;
bool _awakeOn = true;
@override @override
void initState() { void initState() {
...@@ -30,11 +32,13 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ...@@ -30,11 +32,13 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final a = await Lock.available(); final a = await Lock.available();
final l = await Lock.enabled(); final l = await Lock.enabled();
final w = await Watcher.enabled(); final w = await Watcher.enabled();
final k = await Prefs.keepAwake();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_lockAvailable = a; _lockAvailable = a;
_lockOn = l; _lockOn = l;
_watchOn = w; _watchOn = w;
_awakeOn = k;
}); });
} }
...@@ -42,6 +46,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ...@@ -42,6 +46,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = ref.watch(sessionProvider); final session = ref.watch(sessionProvider);
final usage = ref.watch(usageProvider); final usage = ref.watch(usageProvider);
final scale = ref.watch(textScaleProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
...@@ -100,6 +105,56 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ...@@ -100,6 +105,56 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const _SectionLabel('Display'),
Card(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
child: Row(
children: [
const Text('Text size', style: TextStyle(fontSize: 14)),
const Spacer(),
Text('${(scale * 100).round()}%',
style: const TextStyle(fontSize: 12, color: Ember.textDim)),
],
),
),
Slider(
value: scale,
min: TextScaleNotifier.min,
max: TextScaleNotifier.max,
divisions: 9,
activeColor: Ember.flame,
label: '${(scale * 100).round()}%',
onChanged: (v) => ref.read(textScaleProvider.notifier).set(v),
),
const Padding(
padding: EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Text(
'Chats, files and code — on top of the phone\'s own text size.',
style: TextStyle(fontSize: 11, color: Ember.textFaint),
),
),
const Divider(height: 1),
SwitchListTile(
value: _awakeOn,
activeThumbColor: Ember.flame,
title: const Text('Keep screen on during live runs',
style: TextStyle(fontSize: 14)),
subtitle: const Text(
'Only while a chat with a running task is open.',
style: TextStyle(fontSize: 11, color: Ember.textFaint),
),
onChanged: (v) async {
setState(() => _awakeOn = v);
await Prefs.setKeepAwake(v);
},
),
],
),
),
const SizedBox(height: 20),
const _SectionLabel('Background'), const _SectionLabel('Background'),
Card( Card(
child: SwitchListTile( child: SwitchListTile(
...@@ -186,7 +241,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> { ...@@ -186,7 +241,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)), style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
), ),
const Center( const Center(
child: Text('1.0.0', child: Text(kAppVersion,
style: TextStyle(color: Ember.textFaint, fontSize: 11)), style: TextStyle(color: Ember.textFaint, fontSize: 11)),
), ),
], ],
......
...@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; ...@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/lock.dart'; import 'core/lock.dart';
import 'core/notifications.dart'; import 'core/notifications.dart';
import 'core/prefs.dart';
import 'core/watcher.dart'; import 'core/watcher.dart';
import 'core/providers.dart'; import 'core/providers.dart';
import 'core/theme.dart'; import 'core/theme.dart';
...@@ -35,10 +36,20 @@ class SonOfAntonApp extends ConsumerWidget { ...@@ -35,10 +36,20 @@ class SonOfAntonApp extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final session = ref.watch(sessionProvider); final session = ref.watch(sessionProvider);
final scale = ref.watch(textScaleProvider);
return MaterialApp( return MaterialApp(
title: 'Son of Anton', title: 'Son of Anton',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: buildTheme(), theme: buildTheme(),
// The in-app text size multiplies the phone's own setting instead of
// replacing it, so someone who already enlarged system text keeps that.
builder: (context, child) {
final mq = MediaQuery.of(context);
return MediaQuery(
data: mq.copyWith(textScaler: TextScaler.linear(mq.textScaler.scale(14) / 14 * scale)),
child: child!,
);
},
// Deliberately not go_router: this app has one auth gate and a tab shell. // 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. // A declarative router here would be indirection with nothing to route.
home: session.isAuthed ? const _Guarded(child: HomeShell()) : const LoginScreen(), home: session.isAuthed ? const _Guarded(child: HomeShell()) : const LoginScreen(),
......
...@@ -5,10 +5,10 @@ packages: ...@@ -5,10 +5,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: archive name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.0" version: "4.0.9"
args: args:
dependency: transitive dependency: transitive
description: description:
...@@ -25,6 +25,22 @@ packages: ...@@ -25,6 +25,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" version: "2.13.1"
barcode:
dependency: transitive
description:
name: barcode
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
url: "https://pub.dev"
source: hosted
version: "2.2.9"
bidi:
dependency: transitive
description:
name: bidi
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
url: "https://pub.dev"
source: hosted
version: "2.0.13"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
...@@ -664,6 +680,22 @@ packages: ...@@ -664,6 +680,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
package_info_plus:
dependency: transitive
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path: path:
dependency: transitive dependency: transitive
description: description:
...@@ -672,6 +704,14 @@ packages: ...@@ -672,6 +704,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider: path_provider:
dependency: "direct main" dependency: "direct main"
description: description:
...@@ -720,6 +760,22 @@ packages: ...@@ -720,6 +760,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
pdf:
dependency: transitive
description:
name: pdf
sha256: "517df47af468734a23c8b513c0c6a8a62357403ab1b8ab7fad1606576a9dbdd0"
url: "https://pub.dev"
source: hosted
version: "3.13.0"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
...@@ -752,6 +808,14 @@ packages: ...@@ -752,6 +808,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.2" version: "6.5.2"
printing:
dependency: "direct main"
description:
name: printing
sha256: f6cd14c768c1352dd37a958a3ee351aa9ce305b398218d3acd86389d5f7ecad1
url: "https://pub.dev"
source: hosted
version: "5.15.0"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
...@@ -760,6 +824,14 @@ packages: ...@@ -760,6 +824,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.1" version: "2.2.1"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
re_editor: re_editor:
dependency: "direct main" dependency: "direct main"
description: description:
...@@ -1005,6 +1077,22 @@ packages: ...@@ -1005,6 +1077,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.3.0" version: "15.3.0"
wakelock_plus:
dependency: "direct main"
description:
name: wakelock_plus
sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1
url: "https://pub.dev"
source: hosted
version: "1.5.2"
wakelock_plus_platform_interface:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "764c25504562abc8ac3406f5d175f126b276e6ba916c7c2675690b73368ca384"
url: "https://pub.dev"
source: hosted
version: "1.7.0"
web: web:
dependency: transitive dependency: transitive
description: description:
......
name: son_of_anton name: son_of_anton
description: Drive Claude Code against your GitLab repos from your phone. description: Drive Claude Code against your GitLab repos from your phone.
publish_to: 'none' publish_to: 'none'
version: 1.0.0+1 version: 1.4.0+14
environment: environment:
sdk: ^3.8.0 sdk: ^3.8.0
...@@ -55,6 +55,8 @@ dependencies: ...@@ -55,6 +55,8 @@ dependencies:
timeago: ^3.7.0 timeago: ^3.7.0
mime: ^2.0.0 mime: ^2.0.0
collection: ^1.18.0 collection: ^1.18.0
printing: ^5.15.0
wakelock_plus: ^1.5.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
......
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:son_of_anton/core/api/son_api.dart';
import 'package:son_of_anton/features/files/file_actions.dart';
import 'package:son_of_anton/features/files/pdf_export.dart';
import 'package:son_of_anton/features/files/pdf_flow.dart';
/// Records which repo paths an image loader asked for, without a network.
class _FakeApi extends SonApi {
_FakeApi() : super(baseUrl: 'https://example.invalid', token: 't');
final asked = <String>[];
@override
Future<Uint8List> download(String project, List<String> paths, {bool inline = false}) async {
asked.add(paths.single);
return Uint8List.fromList([1, 2, 3]);
}
}
List<String> _rows(String html) =>
RegExp(r'<div class="l">.*?</div>').allMatches(html).map((m) => m.group(0)!).toList();
void main() {
group('code listing for PDF', () {
test('a comment spanning lines keeps every row balanced and coloured', () {
final rows = _rows(PdfExport.listingHtml('/* one\ntwo\nthree */\nfinal x = 1;\n', 'dart'));
expect(rows.length, 4, reason: 'the trailing newline must not add an empty row');
for (final r in rows) {
expect('<span'.allMatches(r).length, '</span>'.allMatches(r).length, reason: r);
}
expect(rows[1], contains('two'));
expect(rows[1], contains('hljs-comment'),
reason: 'the middle line of a block comment is still a comment');
});
test('rows are numbered from 1', () {
final rows = _rows(PdfExport.listingHtml('a\nb\nc', ''));
expect(rows.map((r) => RegExp(r'<span class="n">(\d+)</span>').firstMatch(r)!.group(1)),
['1', '2', '3']);
});
test('plain text is escaped, not interpreted', () {
expect(PdfExport.listingHtml('<b>&"', ''), contains('&lt;b&gt;&amp;&quot;'));
});
test('an empty line still gets a row of height', () {
final rows = _rows(PdfExport.listingHtml('a\n\nb', ''));
expect(rows.length, 3);
expect(rows[1], contains('&#8203;'));
});
});
group('language detection', () {
test('by extension, alias and well-known name', () {
expect(PdfExport.languageFor('src/a.ts'), 'typescript');
expect(PdfExport.languageFor('lib/main.dart'), 'dart');
expect(PdfExport.languageFor('Dockerfile'), 'dockerfile');
expect(PdfExport.languageFor('notes.unknownext'), '');
expect(PdfExport.isMarkdown('docs/README.MD'), isTrue);
});
});
group('names', () {
test('what a download is saved as', () {
expect(FileActions.downloadName(['a/b/c.txt'], isDir: false), 'c.txt');
expect(FileActions.downloadName(['a/b'], isDir: true), 'b.zip');
expect(FileActions.downloadName(['.'], isDir: true, folderName: 'clubphp'), 'clubphp.zip');
expect(FileActions.downloadName(['x', 'y'], isDir: false, folderName: 'docs'), 'docs.zip');
});
test('file-name safety', () {
expect(safeFileName('Fix: a/b?'), 'Fix a b');
expect(safeFileName(' '), 'export');
expect(stemOf('report.final.md'), 'report.final');
expect(stemOf('.env'), '.env');
expect(parentOf('a/b/c.md'), 'a/b');
expect(parentOf('c.md'), '');
expect(isZipName('X.ZIP'), isTrue);
expect(isImageName('shot.webp'), isTrue);
});
});
test('markdown images resolve against the document and never climb out', () async {
final api = _FakeApi();
final load = repoImages(api, 'p', 'docs/guide/intro.md');
await load('img/a.png');
await load('./img/b.png');
await load('../shared/c.png');
await load('/logo.png');
expect(await load('../../../etc/passwd'), isNull);
expect(api.asked, ['docs/guide/img/a.png', 'docs/guide/img/b.png', 'docs/shared/c.png', 'logo.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