Public Access
The bottom bar is Vaults · Connections · Settings, drawn as Material 3 pills with icons from the embedded Material font and a count of open sessions on Connections, which replaces the strip of shells above the bar. Vaults is the open vault: its header carries the name as a switch to the vaults screen, the sync light and a lock icon, and a Hosts · Keychain · Snippets segmented control sits under it. The keychain and snippets move there from Settings (IsVaultSurface lights the tab; IsMoreSurface no longer lists them), and back from either goes to the hosts. Settings becomes an account card and grouped sections: security, vaults and history, files, this app. Host rows are cards with a terminal tile and the live dot on its corner. The connect sheet names the host and its address, labels its box, focuses it as it opens, reveals the password on request, and puts CANCEL left and CONNECT right; every two-button row on the head now confirms on the right. While a hosts-screen sheet is up the header and bar are dimmed too and a tap on them lowers it, and the bar stands down while the software keyboard is up. The keychain gains a floating + and a sheet of the desktop's five adds (generate a key, paste a key, a password, a bucket, a tag) over the editors VaultViewModel already had, drawn as a full-screen page with labels above the boxes; a chosen item gets EDIT and COPY PUBLIC KEY beside MOVE and DELETE. Snippets get a floating + and cards that say RUNS or INSERTS. Files trade the seven-button block for a selection bar (save, and a ⋮ sheet with rename, move, permissions and a red delete), folder and file tiles, an upload button, and header actions for a terminal on the same host, refresh and close; a terminal's + sheet opens SFTP on that terminal's own host. The theme takes the audit's visual findings: Fluent's ClipToBounds is cleared on glowing buttons so the glow is no longer cut to a square (the desktop's Button.accent too), prose is Montserrat with mono kept for addresses and code, disabled secondary buttons are quieter than enabled ones, Fluent's dropdown, checkbox, switch, focus and selection brushes come from the palette, touch no longer leaves hover fills behind, and lists, nav entries and icon buttons carry names for TalkBack. Builds clean; checked on the emulator end to end: unlock, the three vault views, adding and deleting a password, connecting through the new sheet, files on the terminal's host with the selection bar and its sheet. App tests 727 of 733 pass with 5 skipped; the one failure, TheCountsAPhoneShows_ FollowEveryForwardsState, passes three runs of three alone and is untouched here. Layout tests pass.
162 lines
6.8 KiB
C#
162 lines
6.8 KiB
C#
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Markup.Xaml;
|
|
|
|
using DodoSSH.Client.Android.Platform;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
|
|
namespace DodoSSH.Client.Android.Views;
|
|
|
|
/// <summary>Designs 06 and 08 — one remote pane, over SFTP or over a bucket.</summary>
|
|
internal sealed partial class FilesScreen : UserControl
|
|
{
|
|
public FilesScreen() => AvaloniaXamlLoader.Load(this);
|
|
|
|
/// <summary>
|
|
/// Opens a directory on a single tap.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The desktop pane opens on a double tap and on Enter. A phone has neither, so the gesture has to be
|
|
/// the one gesture there is — and that is why this is here rather than in the shared view model: making
|
|
/// selection navigate would give the desktop a single click that walks into a directory.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="InputElement.Tapped"/> rather than a command on the row, because the row must stay a
|
|
/// plain <see cref="Border"/>: a button as the item template swallows the press before the list sees it
|
|
/// — it marks the pointer event handled, and the list's own handler is not registered for handled
|
|
/// events — leaving nothing selected and every command that reads the selection doing nothing. Tapped
|
|
/// fires after the list has moved its selection, which is what lets this read it.
|
|
/// </para>
|
|
/// <para>
|
|
/// A file is left selected rather than downloaded, and that is deliberate now rather than forced: the
|
|
/// actions below act on the selection, and a tap that started a transfer would make selecting a row to
|
|
/// read its size the same gesture as fetching it.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <summary>Tells the screen a row's tick moved, so the count above the listing keeps up.</summary>
|
|
/// <remarks>
|
|
/// The desktop's own handler, for the reason given there: the count is a property of the collection
|
|
/// rather than of any row, and each listing replaces every row it has. See
|
|
/// <c>TransfersViewModel.RemoteChecksChanged</c>.
|
|
/// </remarks>
|
|
/// <summary>An entry in the actions sheet was chosen: the sheet goes down as its command runs.</summary>
|
|
/// <remarks>
|
|
/// Three of the four open a question under the listing, and the sheet left over it would be the one thing
|
|
/// in the way of answering. The command is bound in the markup; this only lowers the sheet.
|
|
/// </remarks>
|
|
private void OnEntryMenuChosen(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (DataContext is TransfersViewModel transfers)
|
|
{
|
|
transfers.IsEntryMenuOpen = false;
|
|
}
|
|
}
|
|
|
|
private void OnRemoteRowChecked(object? sender, RoutedEventArgs e) =>
|
|
(DataContext as TransfersViewModel)?.RemoteChecksChanged();
|
|
|
|
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
|
|
{
|
|
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
|
|
{
|
|
transfers.OpenRemoteCommand.Execute(null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Picks documents in the system picker and queues them for upload.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// In the head rather than in the shared view model, for the reason every other platform difference is:
|
|
/// the picker is Android's, the staging directory is this application's cache, and the desktop reaches
|
|
/// its local files by browsing a pane that does not exist here. What crosses back into shared code is
|
|
/// what the queue understands — paths — through <see cref="TransfersViewModel.QueueStagedUploads"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Failures land in the screen's own status line</b>, which is where every other refusal on this
|
|
/// screen already is. The picker itself is a trip out to another application, and it can come back with
|
|
/// a document that has since been deleted or a grant that was revoked; the exception's message is more
|
|
/// use than "the upload failed", and a phone has nowhere else to put it.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async void OnAddFiles(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (DataContext is not TransfersViewModel transfers || TopLevel.GetTopLevel(this) is not { } top)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var staged = await DocumentStaging.PickAsync(top, CancellationToken.None).ConfigureAwait(true);
|
|
|
|
if (staged.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
transfers.QueueStagedUploads(staged);
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
transfers.Status = $"Those files could not be read: {exception.Message}";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Asks where the chosen remote file should be saved, then queues it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The other direction, and the asymmetry with <see cref="OnAddFiles"/> is the platform's rather than
|
|
/// this screen's: coming in, several documents can be pointed at in one trip; going out, the save picker
|
|
/// names one destination, so this acts on the selected row. Asking for five destinations in a row to
|
|
/// download five files would be a worse screen than pressing the button five times.
|
|
/// </para>
|
|
/// <para>
|
|
/// Nothing is queued when the picker is dismissed. The document it makes when it is *not* dismissed
|
|
/// exists from that moment, which is why the queueing follows immediately — see
|
|
/// <c>DocumentStaging.PickDestinationAsync</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async void OnSaveFile(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (DataContext is not TransfersViewModel transfers || TopLevel.GetTopLevel(this) is not { } top)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// From the actions sheet as well as the bar, and the sheet goes down either way: the save picker is
|
|
// about to cover the screen, and coming back to the sheet still up would be coming back to a question
|
|
// already answered.
|
|
transfers.IsEntryMenuOpen = false;
|
|
|
|
if (transfers.SelectedRemoteEntry is not { IsFile: true } row)
|
|
{
|
|
transfers.Status = "Choose a file on the host to save.";
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (await DocumentStaging.PickDestinationAsync(top, row.Name).ConfigureAwait(true)
|
|
is not { } destination)
|
|
{
|
|
return;
|
|
}
|
|
|
|
transfers.QueueDeliveredDownload(
|
|
row,
|
|
DocumentStaging.NewStagingPath(row.Name),
|
|
path => DocumentStaging.DeliverAsync(destination, path));
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
transfers.Status = $"That file could not be saved: {exception.Message}";
|
|
}
|
|
}
|
|
}
|