Public Access
Three things a user reported, one of which was a real bug and one of which was
not the bug it looked like.
**A vault shared with somebody never reached their machine.** The grant was
correct at both ends: the sharing client verified the recipient's key against the
key log and wrapped every generation to it, the server stored it, and /me would
have returned it. Nothing asked. VaultSession.RefreshVaultsAsync — the method
whose own summary says it is "called after a share and on a periodic pass" — had
no caller anywhere in the application, so the vault list was whatever the last
browser sign-in cached. A restart did not help: an offline unlock reads that same
cache. The vault appeared only if the recipient happened to sign in through the
browser again, which is why this looked like sharing being broken rather than
like a list that was never re-read.
So every synchronisation pass now re-reads it, before it syncs. SyncOnceAsync
takes the whole server rather than its sync half for that reason, and the order
matters: a vault admitted by the refresh is one that same pass then pulls, where
the other order would show a newly shared vault as an empty one until the minute
after. The shell is told only when the set actually changed — it rebuilds the tab
strip's vault menu from the session's list, and doing that on every quiet pass
would rebuild a menu once a minute for nothing.
The test needed the fake server to be able to do something no test here had
needed before: hand this account a vault it did not make. ShareVaultWithMe wraps
a real key to the encryption key this account enrolled, so the keyring opens it
exactly as it opens a real colleague's — a helper that filled the field with
bytes would let a vault appear in the list and never prove it could be read.
**Adding an S3 bucket on the desktop works, and could not be found.** The report
was that it is not possible; driving the real XAML headlessly says otherwise —
Keychain, + BUCKET, and the editor saves. What is true is that S3 is where
somebody goes looking, and from there SELECT BUCKET opened a combo box with
nothing in it and no sentence anywhere saying that a bucket is a keychain item.
From where the user was standing that is indistinguishable from an application
with no way to add one.
The empty state now says what a bucket is and offers a button that lands on the
keychain with the editor already open — navigating to the screen and leaving
+ BUCKET to be found among five buttons would be most of the same problem. The
phone gets the sentence and no button: its keychain screen reads and deletes and
edits nothing, so there is no editor to send anybody to, and naming the machine
that has one beats an empty control that reads as a screen still loading.
The keychain screen's layout test grew the two categories it never covered.
Tags and buckets arrived after it was written, and the header strip it measures
is one that has overflowed twice before.
**A vault can now be deleted.** DELETE /api/v1/vaults/{id}, gated on Admin —
the line the rename already drew, for a stronger version of its reason, since
this takes the vault from everybody in it at once. The row is soft-deleted and
every grant to it withdrawn in one write; VaultAccessService filters on the stamp
at both ends, so from that moment the vault is absent from every member's /me and
every call naming it answers 404. Their clients notice on the pass described
above.
The team behind it is archived when it owned nothing else, which is the mirror of
renaming it: a vault made from the vaults screen gets a team named after it that
nobody was ever shown, and leaving that behind would leave a membership list no
screen has a row for. That is a second call rather than one transaction —
archiving is TeamService's, it refuses while a team owns vaults, and it can only
tell that this one no longer does once the deletion is committed. A crash between
the two leaves an empty team: invisible, archivable afterwards, harmless, and a
better failure than a vault that could not be deleted because tidying up after it
did not work.
Two refusals worth stating. The personal vault cannot be deleted at either end:
it is created by enrollment, everything filed nowhere else lives in it, and no
call would make another. And the items are kept — ciphertext behind a vault
nothing will resolve, so deleting them buys no confidentiality while destroying
what an operator undoing a mistake would need.
The client drops the key from the keyring and the row from the cache rather than
waiting for a refresh, so the list is right immediately; the items stay, as they
stay for a vault whose grant was withdrawn, because a copy is on every other
member's machine too and removing these rows would be the client pretending to a
reach it does not have. The confirmation says that out loud before it is
answered. It is the one sentence this screen must not leave implied: deletion is
no more retroactive than revocation is. See ADR 0001.
Desktop only, deliberately. The Android vaults screen offers no rename and no
hand-over either, so adding delete alone there would be the one destructive vault
operation on a screen with no other.
Three places asserted that a vault can never be deleted — TeamService's refusal
message, the TeamNotEmpty problem code, and ADR 0009 — and each now names the
route instead.
367 lines
21 KiB
XML
367 lines
21 KiB
XML
<UserControl xmlns="https://github.com/avaloniaui"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
|
xmlns:views="using:DodoSSH.Client.Android.Views"
|
|
x:Class="DodoSSH.Client.Android.Views.FilesScreen"
|
|
x:DataType="vm:TransfersViewModel"
|
|
Background="{StaticResource Canvas}">
|
|
|
|
<!--
|
|
Designs 06 and 08 — SFTP and S3, which are one screen.
|
|
|
|
Below the picker the two are the same thing: a bucket and an SFTP host are both an IRemoteFileStore, and
|
|
the pane, the queue and every transfer were written once. What differs is which picker is offered and
|
|
what the button says — OPEN for a bucket, because S3 is request-per-operation and nothing stays
|
|
connected. Drawing them twice would be two copies of a screen obliged to stay identical.
|
|
|
|
**One pane, not two.** The design's LOCAL / REMOTE toggle is not drawn, and this is the decision
|
|
docs/android-port.md took before any of this was built: Android has scoped storage, so there is no
|
|
browsable local filesystem to put in the other half. TransfersViewModel's local pane — LocalPath,
|
|
LocalRoots, LocalEntries — is desktop-only and is left alone here rather than shown empty.
|
|
|
|
◆ **ADD FILES is the way in, and it is the system document picker rather than an UPLOAD button.** There
|
|
is nothing local to select from, so the gesture cannot be "choose on the left, press the arrow": it is
|
|
"point at a document wherever it lives, and it goes to the directory showing". What Android hands back
|
|
is a `content://` URI, so `DocumentStaging` copies it into this application's cache and queues the copy —
|
|
the queue needs a path, a length and a seek, and a document URI promises none of the three. See that
|
|
class for why the copy is a requirement rather than a shortcut, and `QueueStagedUploads` for when it is
|
|
deleted again.
|
|
|
|
◆ **SAVE FILE is the way out, and it is the save picker rather than a DOWNLOAD button.** `QueueDownloads`
|
|
writes to `Path.Combine(LocalPath, name)`, and `LocalPath` on Android is the application's own private
|
|
directory — a download that way would report success and leave the file where the person who asked for
|
|
it cannot open it. So this head does not use it: `QueueDeliveredDownload` runs the transfer into the
|
|
cache and hands the finished bytes to the document `ACTION_CREATE_DOCUMENT` made.
|
|
|
|
The destination is chosen *before* the transfer, which is a decision with a visible cost — the picker
|
|
creates the document when it is dismissed, so a download that then fails leaves an empty file where it
|
|
was pointed. The alternative is a picker raised minutes later, over whatever the person moved on to and
|
|
frequently while this application is backgrounded, where Android will not show one at all.
|
|
|
|
So what ships is browsing a remote and moving files both ways, plus the two remote-side operations that
|
|
need nothing local — opening a directory and deleting. The queue is drawn under the actions, because
|
|
this head can now fill it.
|
|
|
|
◆ **The host key prompts are here too.** File transfer is a second, separate authenticated connection
|
|
and it makes its own trust decision — the host records a second login. So this screen carries its own
|
|
unknown-key prompt and its own changed-key refusal, over TransfersViewModel's own PendingHostKey, and
|
|
the refusal has no continue button here either.
|
|
-->
|
|
|
|
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
|
|
|
|
<!-- ============ header ============ -->
|
|
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
|
<Button Grid.Column="0" Classes="icon" Content="←"
|
|
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
|
|
CommandParameter="{x:Static vm:ShellScreen.More}" />
|
|
|
|
<TextBlock Grid.Column="1" Classes="heading" Text="SFTP" Margin="4,0"
|
|
IsVisible="{Binding ShowsHostPicker}" />
|
|
<TextBlock Grid.Column="1" Classes="heading" Text="S3" Margin="4,0"
|
|
IsVisible="{Binding ShowsBucketPicker}" />
|
|
|
|
<!-- What is actually open, named. On a bucket this reads s3://name. -->
|
|
<Border Grid.Column="2" Classes="tag" Margin="8,0" HorizontalAlignment="Left"
|
|
IsVisible="{Binding IsConnected}">
|
|
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
|
|
</Border>
|
|
|
|
<Button Grid.Column="3" Classes="icon" Content="↻" Command="{Binding RefreshRemoteCommand}"
|
|
IsVisible="{Binding IsConnected}" />
|
|
</Grid>
|
|
|
|
<!-- ============ opening one ============ -->
|
|
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding !IsConnected}">
|
|
<StackPanel Spacing="10">
|
|
|
|
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
|
|
ItemsSource="{Binding Hosts}" SelectedItem="{Binding SelectedHost}"
|
|
PlaceholderText="choose a host">
|
|
<ComboBox.ItemTemplate>
|
|
<DataTemplate x:DataType="vm:HostRowViewModel">
|
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
|
</DataTemplate>
|
|
</ComboBox.ItemTemplate>
|
|
</ComboBox>
|
|
|
|
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsBucketChoice}"
|
|
ItemsSource="{Binding Buckets}" SelectedItem="{Binding SelectedBucket}"
|
|
PlaceholderText="choose a bucket">
|
|
<ComboBox.ItemTemplate>
|
|
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
|
|
<StackPanel Spacing="1">
|
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
|
<TextBlock Classes="detail" Text="{Binding Description}" />
|
|
</StackPanel>
|
|
</DataTemplate>
|
|
</ComboBox.ItemTemplate>
|
|
</ComboBox>
|
|
|
|
<!--
|
|
A picker with nothing in it says nothing, and here it cannot say much more: this head's keychain
|
|
screen reads and deletes and edits nothing, so there is no bucket editor to send anybody to. The
|
|
sentence is therefore the whole of what this can offer, and it names the machine that can — which
|
|
is better than an empty control that reads as a screen still loading. The desktop's counterpart
|
|
has a button, because over there the editor is two clicks away.
|
|
-->
|
|
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding ShowsNoBuckets}"
|
|
Text="No buckets yet. A bucket is a keychain item — its name, keys and endpoint — and this phone can read the keychain but not add to it. Add one on the desktop and it syncs here." />
|
|
|
|
<!--
|
|
Its own box, deliberately not the hosts screen's. This is a separate authentication, so a password
|
|
typed to open a terminal has not been offered here — and quietly reusing it would make a one-time
|
|
password appear to work twice.
|
|
-->
|
|
<TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
|
Text="{Binding TypedPassword}" PlaceholderText="password" />
|
|
|
|
<!--
|
|
Gone rather than disabled when there is no bucket to open, because a disabled OPEN under an empty
|
|
picker is the same dead end with a grey button on it. The sentence above is what the card carries
|
|
in that state.
|
|
-->
|
|
<Button Classes="primary" Content="{Binding ConnectLabel}" Command="{Binding ConnectCommand}"
|
|
IsVisible="{Binding !ShowsNoBuckets}" IsEnabled="{Binding !IsBusy}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!-- ============ ◆ the host key decisions ============ -->
|
|
<!--
|
|
The unknown key: the fingerprint in full, wrapping rather than clipping, and two ways out.
|
|
-->
|
|
<Border Grid.Row="2" Margin="12,0,12,8" IsVisible="{Binding HasPendingHostKey}"
|
|
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
|
BorderThickness="1" CornerRadius="12" Padding="14">
|
|
<StackPanel Spacing="9">
|
|
<TextBlock Classes="label" Foreground="{StaticResource Warn}" Text="UNKNOWN HOST KEY" />
|
|
<TextBlock Classes="body">
|
|
<Run Text="First contact with" />
|
|
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
|
<Run Text="·" />
|
|
<Run Text="{Binding PendingHostKey.Port}" />
|
|
<Run Text=". Nothing in this keychain has approved this key." />
|
|
</TextBlock>
|
|
<TextBlock Classes="label" Foreground="{StaticResource TextFaint}"
|
|
Text="{Binding PendingHostKey.Algorithm}" />
|
|
<!-- ◆ In full, and wrapping rather than clipping. See the .fingerprint style. -->
|
|
<SelectableTextBlock Classes="fingerprint" Text="{Binding PendingHostKey.Fingerprint}" />
|
|
<Grid ColumnDefinitions="*,8,*">
|
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="TRUST AND OPEN"
|
|
Command="{Binding TrustHostKeyCommand}" />
|
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
|
Command="{Binding RejectHostKeyCommand}" />
|
|
</Grid>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!--
|
|
◆ The changed key, and it has no continue button. Presenting this as dismissible is the one design
|
|
mistake that matters here — the same rule the hosts screen's sheet follows.
|
|
-->
|
|
<Border Grid.Row="2" Margin="12,0,12,8" IsVisible="{Binding HasHostKeyMismatch}"
|
|
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
|
BorderThickness="1" CornerRadius="12" Padding="14">
|
|
<StackPanel Spacing="9">
|
|
<TextBlock Classes="label" Foreground="{StaticResource Danger}" Text="HOST KEY CHANGED — REFUSED" />
|
|
<TextBlock Classes="body" Foreground="{StaticResource DangerText}" Text="{Binding HostKeyMismatch}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!-- ============ where we are ============ -->
|
|
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
|
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
|
|
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
|
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="9"
|
|
Command="{Binding RemoteUpCommand}">
|
|
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
|
|
</Button>
|
|
<ItemsControl ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
|
<ItemsControl.ItemsPanel>
|
|
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="2" /></ItemsPanelTemplate>
|
|
</ItemsControl.ItemsPanel>
|
|
<ItemsControl.ItemTemplate>
|
|
<DataTemplate x:DataType="vm:CrumbViewModel">
|
|
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="9"
|
|
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
|
CommandParameter="{Binding Path}">
|
|
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
|
|
</Button>
|
|
</DataTemplate>
|
|
</ItemsControl.ItemTemplate>
|
|
</ItemsControl>
|
|
</StackPanel>
|
|
</ScrollViewer>
|
|
|
|
<!-- ============ the listing ============ -->
|
|
<Panel Grid.Row="4">
|
|
|
|
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
|
VerticalAlignment="Top" Text="{Binding Status}" />
|
|
|
|
<!--
|
|
Tapped on the list rather than a Command on the row, and that is not a preference.
|
|
|
|
A Button as the ItemTemplate root swallows the press: the ListBoxItem never sees it, SelectedItem
|
|
stays null, and every command that reads SelectedRemoteEntry — opening a directory, deleting —
|
|
quietly does nothing. Measured, not assumed. So the row is a Border, selection works as it does on
|
|
every other list in this head, and the one phone-specific behaviour is here: a single tap on a
|
|
directory opens it, because a phone has no double-tap-to-open and no Enter.
|
|
|
|
Not driven from OnSelectedRemoteEntryChanged, which would have been the tidier-looking place: that
|
|
setter is in the shared view model, and the desktop pane deliberately opens on DoubleTapped — making
|
|
selection navigate would send a single desktop click walking into a directory.
|
|
-->
|
|
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
|
|
Background="Transparent" BorderThickness="0" IsVisible="{Binding IsConnected}"
|
|
Tapped="OnRemoteEntryTapped">
|
|
<ListBox.Styles>
|
|
<Style Selector="ListBoxItem">
|
|
<Setter Property="Padding" Value="0" />
|
|
<Setter Property="MinHeight" Value="0" />
|
|
<Setter Property="Margin" Value="10,1" />
|
|
<Setter Property="CornerRadius" Value="10" />
|
|
</Style>
|
|
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
|
<Setter Property="Background" Value="{StaticResource Active}" />
|
|
<Setter Property="CornerRadius" Value="10" />
|
|
</Style>
|
|
</ListBox.Styles>
|
|
<ListBox.ItemTemplate>
|
|
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
|
<Border MinHeight="52" Padding="10,7">
|
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
|
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
|
VerticalAlignment="Center" FontSize="12" Text="▸"
|
|
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
|
|
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
|
VerticalAlignment="Center" FontSize="12" Text="·"
|
|
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
|
|
|
|
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
|
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
|
|
TextTrimming="CharacterEllipsis" />
|
|
<StackPanel Orientation="Horizontal" Spacing="7">
|
|
<TextBlock Classes="detail" Text="{Binding Size}"
|
|
IsVisible="{Binding Size, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
|
<!-- Real POSIX bits from the server, not a plausible-looking mode. -->
|
|
<TextBlock Classes="detail" Text="{Binding Permissions}" />
|
|
</StackPanel>
|
|
</StackPanel>
|
|
|
|
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Modified}"
|
|
VerticalAlignment="Center" />
|
|
</Grid>
|
|
</Border>
|
|
</DataTemplate>
|
|
</ListBox.ItemTemplate>
|
|
</ListBox>
|
|
</Panel>
|
|
|
|
<!-- ============ what to do with the chosen entry ============ -->
|
|
<Border Grid.Row="5" IsVisible="{Binding IsConnected}" Background="{StaticResource Chrome}"
|
|
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
|
<StackPanel Spacing="9">
|
|
|
|
<!-- ◆ Counted, and it replaces the actions in place rather than stacking over the row it names. -->
|
|
<Border IsVisible="{Binding IsConfirmingRemoteDeletion}" Background="{StaticResource DangerWash}"
|
|
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1" CornerRadius="12"
|
|
Padding="13,11">
|
|
<StackPanel Spacing="6">
|
|
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
|
Text="{Binding PendingRemoteDeletion.Question}" />
|
|
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
|
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
|
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
|
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE"
|
|
Command="{Binding ConfirmDeleteRemoteCommand}" />
|
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP"
|
|
Command="{Binding CancelDeleteRemoteCommand}" />
|
|
</Grid>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!--
|
|
◆ The queue, above the actions and only when it has something in it. Bounded and scrolling rather
|
|
than growing: five files queued must not push the buttons off the bottom of the screen, which on a
|
|
phone is how a screen becomes unusable rather than merely tall.
|
|
-->
|
|
<ScrollViewer MaxHeight="164" IsVisible="{Binding HasTransfers}"
|
|
VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
|
<ItemsControl ItemsSource="{Binding Transfers}">
|
|
<ItemsControl.ItemTemplate>
|
|
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
|
<Grid ColumnDefinitions="14,*,Auto" Margin="0,3">
|
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="12" Text="{Binding Arrow}"
|
|
VerticalAlignment="Center" Foreground="{StaticResource AccentText}" />
|
|
|
|
<StackPanel Grid.Column="1" Margin="8,0" Spacing="3">
|
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Name}"
|
|
TextTrimming="CharacterEllipsis" />
|
|
<ProgressBar Height="3" Minimum="0" Maximum="100" Value="{Binding Percent}"
|
|
Foreground="{StaticResource Accent}"
|
|
Background="{StaticResource Raised}" />
|
|
<TextBlock Classes="detail" Text="{Binding Progress}"
|
|
TextTrimming="CharacterEllipsis" />
|
|
</StackPanel>
|
|
|
|
<!--
|
|
One button per row, never two: whichever of the three applies to the state it is in.
|
|
A phone row has space for a name, a bar and one 44-pixel target, and the three are
|
|
mutually exclusive by construction — IsRunning and CanRetry cannot both hold.
|
|
-->
|
|
<StackPanel Grid.Column="2" VerticalAlignment="Center">
|
|
<Button Classes="secondary" MinHeight="36" Padding="10,0" Content="STOP"
|
|
IsVisible="{Binding IsRunning}"
|
|
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
|
CommandParameter="{Binding}" />
|
|
<Button Classes="secondary" MinHeight="36" Padding="10,0" Content="{Binding RetryLabel}"
|
|
IsVisible="{Binding CanRetry}"
|
|
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
|
CommandParameter="{Binding}" />
|
|
</StackPanel>
|
|
</Grid>
|
|
</DataTemplate>
|
|
</ItemsControl.ItemTemplate>
|
|
</ItemsControl>
|
|
</ScrollViewer>
|
|
|
|
<Button Classes="secondary" Height="40" Content="CLEAR FINISHED" HorizontalAlignment="Stretch"
|
|
IsVisible="{Binding HasTransfers}" Command="{Binding ClearCompletedCommand}" />
|
|
|
|
<!--
|
|
◆ The two directions, on their own row above the pair below — because DELETE is the button on this
|
|
screen that nothing can undo, and it must not sit at a thumb's width from the ones somebody presses
|
|
often. ADD FILES is the primary of the two: it is the one that needs no selection, and the one this
|
|
screen exists for on a phone.
|
|
|
|
SAVE FILE takes the selected row rather than several, and that asymmetry is the platform's: the
|
|
save picker names one destination. CanDownload is the desktop's own flag for the same question —
|
|
a file is selected and something is connected — and is reused rather than restated here.
|
|
-->
|
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
|
<Button Grid.Column="0" Classes="primary" Height="44" Content="ADD FILES" Click="OnAddFiles" />
|
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="SAVE FILE" Click="OnSaveFile"
|
|
IsEnabled="{Binding CanDownload}" />
|
|
</Grid>
|
|
|
|
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
|
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
|
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
|
|
Command="{Binding DisconnectCommand}" />
|
|
</Grid>
|
|
|
|
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
|
Text="ADD FILES picks documents to send. SAVE FILE asks where the selected file should be kept — the file is created there when you choose it, so a transfer that fails leaves it empty." />
|
|
|
|
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
|
Text="{Binding Status}" />
|
|
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
</Grid>
|
|
|
|
</UserControl>
|