Vydané zdroje
Stáhnout zdroje ZIP
CH-J SM Mobile
Procházejte adresáře a soubory konkrétního vydání aplikace.
CH-J / SM Mobile
175 souborů
sm-mobile-20261016T185235Z-8e93ecc1b59a
Přidáno: · Sestaveno:
1
import 'dart:async';2
import 'dart:io';3
import 'dart:typed_data';4
import 'package:dartssh2/dartssh2.dart';5
import 'package:file_picker/file_picker.dart';6
import 'package:flutter/material.dart';7
import 'package:path_provider/path_provider.dart';8
import 'package:share_plus/share_plus.dart';9
import '../core/app_controller.dart';10
import '../core/failure.dart';11
import '../l10n/strings.dart';12
import '../models/server_profile.dart';13
import '../services/documents.dart';14
import '../services/editor/languages.dart';15
import '../services/editor/recents.dart';16
import '../services/editor/sources.dart';17
import '../services/sftp_service.dart';18
import '../services/vault.dart';19
import 'adaptive.dart';20
import 'common.dart';21
import 'editor/viewers.dart';23
enum FilesTab { remote, device, recent, favorites }25
/// Remote files over SFTP, files on the device, recent and favorite files.26
/// Files only move between the device and a server on an explicit action.27
class FilesPage extends StatefulWidget {28
final AppController controller;29
const FilesPage(this.controller, {super.key});30
@override31
State<FilesPage> createState() => _FilesPageState();32
}34
class _FilesPageState extends State<FilesPage> {35
late FilesTab tab = widget.controller.ssh.connected36
? FilesTab.remote37
: FilesTab.device;38
SftpService? service;39
String path = '/';40
List<SftpName> entries = [];41
bool loading = false;42
TransferToken? transfer;43
int transferred = 0, total = 0;45
// Device tab: files picked in this session and an opened folder.46
final picked = <DeviceDocument>[];47
final folders = <DeviceDocument>[];48
List<DeviceDocument> folderEntries = [];50
AppController get c => widget.controller;52
@override53
void initState() {54
super.initState();55
if (c.ssh.connected) unawaited(load(initial: true));56
}58
@override59
void dispose() {60
transfer?.cancel();61
super.dispose();62
}64
Future<void> load({bool initial = false, String? directory}) async {65
if (!c.ssh.connected) {66
if (mounted) setState(() => loading = false);67
return;68
}69
setState(() => loading = true);70
try {71
service ??= await c.sftp();72
if (!mounted) return;73
final next = initial74
? c.remoteDirectory ?? await service!.home()75
: directory ?? path;76
final result = await service!.list(next);77
if (mounted) {78
setState(() {79
path = next;80
entries = result;81
});82
c.remoteDirectory = next;83
}84
} catch (e) {85
if (mounted) showFailure(context, e);86
} finally {87
if (mounted) setState(() => loading = false);88
}89
}91
Future<void> act(Future<void> Function() action, {bool reload = true}) async {92
try {93
await action();94
if (mounted && reload && tab == FilesTab.remote) await load();95
} catch (e) {96
if (mounted) showFailure(context, e);97
}98
}100
Future<void> runTransfer(101
Future<void> Function(TransferToken, void Function(int, int)) action,102
) async {103
final token = TransferToken();104
setState(() {105
transfer = token;106
transferred = 0;107
total = 0;108
});109
try {110
await action(token, (n, size) {111
if (mounted) {112
setState(() {113
transferred = n;114
total = size;115
});116
}117
});118
} finally {119
if (mounted) setState(() => transfer = null);120
}121
}123
Rect get _origin {124
final o =125
(context.findRenderObject() as RenderBox?)?.localToGlobal(126
Offset.zero,127
) ??128
Offset.zero;129
return Rect.fromLTWH(o.dx, o.dy, 1, 1);130
}132
RemoteSource _remote(String file) => RemoteSource(133
service: () => c.sftpFor(c.ssh.profile!.id),134
profileId: c.ssh.profile!.id,135
profileName: c.ssh.profile!.name,136
path: file,137
);139
Future<void> _open(FileSource source) async {140
await openSource(context, c, source);141
if (mounted) setState(() {});142
}144
// ---------------------------------------------------------------------------145
// Remote actions147
Future<void> upload() => act(() async {148
final file = await c.withSystemDialog(() => FilePicker.pickFile());149
if (file == null || !mounted || !c.vault.unlocked) return;150
final destination = childPath(path, file.name);151
await runTransfer(152
(token, progress) => service!.upload(153
file.readAsByteStream(),154
file.lengthSync() ?? 0,155
destination,156
token,157
progress,158
),159
);160
});162
/// Downloads into a private temporary copy and offers the share sheet.163
Future<void> share(SftpName entry) => act(() async {164
final tempRoot = await getTemporaryDirectory();165
final dir = await Directory(166
'${tempRoot.path}/sm-transfer-${newId()}',167
).create();168
final local = File('${dir.path}/${safeName(entry.filename)}');169
final origin = _origin;170
try {171
await runTransfer(172
(token, progress) => service!.download(173
childPath(path, entry.filename),174
local,175
token,176
progress,177
),178
);179
if (!mounted || !c.vault.unlocked) return;180
await c.withSystemDialog(181
() => SharePlus.instance.share(182
ShareParams(files: [XFile(local.path)], sharePositionOrigin: origin),183
),184
);185
} finally {186
if (await dir.exists()) await dir.delete(recursive: true);187
}188
}, reload: false);190
/// Copies a server file to a place the user picks on the device.191
Future<void> saveToDevice(SftpName entry) => act(() async {192
Uint8List? bytes;193
await runTransfer((token, progress) async {194
final (b, _) = await service!.readFile(195
childPath(path, entry.filename),196
maxBytes: maxViewBytes,197
token: token,198
progress: progress,199
);200
bytes = b;201
});202
if (bytes == null || !mounted) return;203
final doc = await c.withSystemDialog(204
() => c.documents.saveAs(205
entry.filename,206
bytes!,207
mime: mimeOf(entry.filename),208
),209
);210
if (doc != null && mounted) {211
ScaffoldMessenger.of(212
context,213
).showSnackBar(SnackBar(content: Text(context.tr('savedToDevice'))));214
}215
}, reload: false);217
Future<void> newRemoteFile() async {218
final name = await askName(context, 'newFile');219
if (name == null || !mounted) return;220
await act(() async {221
final target = childPath(path, name);222
if (await service!.statOrNull(target) != null) {223
throw const Failure('fileExists');224
}225
await service!.saveFile(target, Uint8List(0));226
if (mounted) await _open(_remote(target));227
});228
}230
// ---------------------------------------------------------------------------231
// Device actions233
Future<void> pickFiles() => act(() async {234
final docs = await c.withSystemDialog(235
() => c.documents.pick(multiple: true),236
);237
if (docs.isEmpty || !mounted) return;238
setState(() {239
for (final d in docs) {240
picked.removeWhere((p) => p.id == d.id);241
picked.insert(0, d);242
}243
});244
if (docs.length == 1) await _open(DeviceSource(c.documents, docs.single));245
}, reload: false);247
Future<void> pickFolder() => act(() async {248
final folder = await c.withSystemDialog(() => c.documents.pickFolder());249
if (folder == null || !mounted) return;250
folders251
..clear()252
..add(folder);253
await _listFolder();254
}, reload: false);256
Future<void> _listFolder() async {257
final list = await c.documents.list(folders.last);258
list.sort(259
(a, b) => a.directory == b.directory260
? a.name.toLowerCase().compareTo(b.name.toLowerCase())261
: a.directory262
? -1263
: 1,264
);265
if (mounted) setState(() => folderEntries = list);266
}268
Future<void> newDeviceFile() async {269
final name = await askName(context, 'newFile', initial: 'untitled.txt');270
if (name == null || !mounted) return;271
await act(() async {272
safeName(name);273
if (folders.isNotEmpty) {274
final doc = await c.documents.create(275
folders.last,276
name,277
Uint8List(0),278
mime: mimeOf(name),279
);280
await _listFolder();281
if (mounted) await _open(DeviceSource.saved(c.documents, doc, 0));282
} else {283
// Saved to a place of the user's choice on the first save.284
await _open(NewSource(name));285
}286
}, reload: false);287
}289
/// Copies a device file to the current server directory.290
Future<void> uploadDocument(DeviceDocument doc) => act(() async {291
if (!c.ssh.connected) throw const Failure('connectToSave');292
final s = service ??= await c.sftp();293
final dir = c.remoteDirectory ?? await s.home();294
final target = childPath(dir, doc.name);295
final bytes = await c.documents.read(doc);296
if (await s.statOrNull(target) != null) {297
if (!mounted) return;298
final ok = await confirm(context, 'overwriteExisting', detail: target);299
if (!ok) return;300
}301
await s.saveFile(target, bytes, force: true);302
if (mounted) {303
ScaffoldMessenger.of(context).showSnackBar(304
SnackBar(content: Text('${context.tr('uploadedTo')} $target')),305
);306
}307
}, reload: false);309
// ---------------------------------------------------------------------------310
// Recent and favorites312
RecentFiles get recents => RecentFiles(c.vault);314
RecentFile _entry(FileSource s) => RecentFile(315
key: s.key,316
kind: s.kind,317
name: s.name,318
location: s.location,319
source: s.toJson(),320
opened: DateTime.now(),321
);323
Future<void> toggleFavorite(FileSource s) => act(() async {324
await recents.setFavorite(_entry(s), !recents.isFavorite(s.key));325
if (mounted) setState(() {});326
}, reload: false);328
Future<void> openRecent(RecentFile r) => act(() async {329
if (r.kind == 'remote' &&330
(!c.ssh.connected || c.ssh.profile?.id != r.source['profileId'])) {331
throw const Failure('connectToOpen');332
}333
await _open(334
sourceFromJson(r.source, documents: c.documents, sftpFor: c.sftpFor),335
);336
}, reload: false);338
Future<void> restoreDraft(Draft d) async {339
final source = sourceFromJson(340
Map<String, dynamic>.from(d.json['source'] as Map),341
documents: c.documents,342
sftpFor: c.sftpFor,343
fallbackName: d.json['name'] as String? ?? 'untitled.txt',344
);345
// The restored draft lives on in the editor (and is stashed again if346
// the Vault locks before it is saved); the others stay recoverable.347
await act(348
() => c.vault.keepDrafts([...c.vault.drafts]..remove(d)),349
reload: false,350
);351
if (mounted) await openSource(context, c, source, draft: d);352
if (mounted) setState(() {});353
}355
List<Draft> get drafts => c.vault.drafts;357
Future<void> discardDrafts() async {358
final ok = await confirm(context, 'discardDrafts');359
if (!ok) return;360
await act(() => c.vault.clearDrafts(), reload: false);361
if (mounted) setState(() {});362
}364
// ---------------------------------------------------------------------------365
// UI367
@override368
Widget build(BuildContext context) => MinHeight(369
minHeight: 260,370
child: Column(371
children: [372
Padding(373
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),374
child: SingleChildScrollView(375
scrollDirection: Axis.horizontal,376
child: SegmentedButton<FilesTab>(377
showSelectedIcon: false,378
segments: [379
ButtonSegment(380
value: FilesTab.remote,381
icon: const Icon(Icons.dns_outlined, size: 18),382
label: Text(context.tr('remoteFiles')),383
),384
ButtonSegment(385
value: FilesTab.device,386
icon: const Icon(Icons.phone_iphone, size: 18),387
label: Text(context.tr('deviceFiles')),388
),389
ButtonSegment(390
value: FilesTab.recent,391
icon: const Icon(Icons.history, size: 18),392
label: Text(context.tr('recentFiles')),393
),394
ButtonSegment(395
value: FilesTab.favorites,396
icon: const Icon(Icons.star_outline, size: 18),397
label: Text(context.tr('favoriteFiles')),398
),399
],400
selected: {tab},401
onSelectionChanged: transfer != null402
? null403
: (v) {404
setState(() => tab = v.first);405
if (tab == FilesTab.remote && service == null) {406
unawaited(load(initial: true));407
}408
},409
),410
),411
),412
if (c.vault.unlocked && drafts.isNotEmpty) _draftsBanner(context),413
if (transfer != null)414
Padding(415
padding: const EdgeInsets.symmetric(horizontal: 12),416
child: Column(417
children: [418
LinearProgressIndicator(419
value: total > 0 ? (transferred / total).clamp(0, 1) : null,420
),421
Row(422
children: [423
Expanded(child: Text('$transferred / $total B')),424
TextButton(425
onPressed: transfer!.cancel,426
child: Text(context.tr('cancel')),427
),428
],429
),430
],431
),432
),433
Expanded(434
child: switch (tab) {435
FilesTab.remote => _remoteView(context),436
FilesTab.device => _deviceView(context),437
FilesTab.recent => _recentView(context, favorites: false),438
FilesTab.favorites => _recentView(context, favorites: true),439
},440
),441
],442
),443
);445
Widget _draftsBanner(BuildContext context) => Card(446
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),447
color: Theme.of(context).colorScheme.secondaryContainer,448
child: Column(449
crossAxisAlignment: CrossAxisAlignment.stretch,450
children: [451
ListTile(452
leading: const Icon(Icons.restore_page_outlined),453
title: Text(context.tr('draftsRecovered')),454
trailing: TextButton(455
onPressed: discardDrafts,456
child: Text(context.tr('discard')),457
),458
),459
for (final d in drafts)460
ListTile(461
dense: true,462
title: Text(d.json['name'] as String? ?? '?'),463
subtitle: Text(d.json['location'] as String? ?? ''),464
trailing: const Icon(Icons.chevron_right),465
onTap: () => restoreDraft(d),466
),467
],468
),469
);471
Widget _remoteView(BuildContext context) {472
if (!c.ssh.connected) {473
return const PageMessage('selectServer', icon: Icons.folder_outlined);474
}475
final busy = loading || transfer != null;476
return Column(477
children: [478
Padding(479
padding: const EdgeInsets.symmetric(horizontal: 12),480
child: Row(481
children: [482
IconButton(483
onPressed: busy484
? null485
: () => load(directory: parentPath(path)),486
tooltip: context.tr('parent'),487
icon: const Icon(Icons.arrow_upward),488
),489
// One line: a deep path must not grow the header; it scrolls.490
Expanded(child: SelectableText(path, maxLines: 1)),491
IconButton(492
onPressed: busy ? null : () => load(),493
tooltip: context.tr('refresh'),494
icon: const Icon(Icons.refresh),495
),496
PopupMenuButton<String>(497
enabled: !busy,498
onSelected: (action) async {499
if (action == 'upload') await upload();500
if (action == 'newFile') await newRemoteFile();501
if (action == 'newFolder' && context.mounted) {502
final name = await askName(context, 'newFolder');503
if (name != null) {504
await act(() => service!.mkdir(childPath(path, name)));505
}506
}507
},508
itemBuilder: (ctx) => ['upload', 'newFile', 'newFolder']509
.map((a) => PopupMenuItem(value: a, child: Text(ctx.tr(a))))510
.toList(),511
),512
],513
),514
),515
if (loading) const LinearProgressIndicator(),516
Expanded(517
child: entries.isEmpty && !loading518
? const PageMessage('emptyDirectory')519
: ListView.builder(520
itemCount: entries.length,521
itemBuilder: (context, i) => _remoteTile(context, entries[i]),522
),523
),524
],525
);526
}528
Widget _remoteTile(BuildContext context, SftpName e) {529
final attrs = e.attr;530
final symlink = attrs.isSymbolicLink;531
final file = childPath(path, e.filename);532
final time = attrs.modifyTime == null533
? '—'534
: DateTime.fromMillisecondsSinceEpoch(535
attrs.modifyTime! * 1000,536
).toLocal().toString().substring(0, 16);537
final favorite =538
attrs.isFile && recents.isFavorite('remote:${c.ssh.profile!.id}:$file');539
return ListTile(540
leading: Icon(541
symlink542
? Icons.link_off543
: attrs.isDirectory544
? Icons.folder_outlined545
: _icon(fileKind(e.filename)),546
),547
title: Text(e.filename),548
subtitle: Text(549
symlink550
? context.tr('symlink')551
: '${context.tr('size')}: ${attrs.size ?? '—'} B · '552
'${context.tr('modified')}: $time',553
),554
onTap: transfer != null || symlink555
? null556
: attrs.isDirectory557
? () => load(directory: file)558
: attrs.isFile559
? () => _open(_remote(file))560
: null,561
trailing: symlink562
? null563
: PopupMenuButton<String>(564
enabled: transfer == null && !loading,565
onSelected: (action) async {566
switch (action) {567
case 'open':568
await _open(_remote(file));569
case 'saveToDevice':570
await saveToDevice(e);571
case 'download':572
await share(e);573
case 'favorite':574
await toggleFavorite(_remote(file));575
case 'delete':576
if (context.mounted &&577
await confirm(context, 'confirmDelete', detail: file)) {578
await act(() => service!.remove(file));579
}580
case 'rename':581
if (!context.mounted) return;582
final name = await askName(583
context,584
'rename',585
initial: e.filename,586
);587
if (name != null &&588
context.mounted &&589
await confirm(590
context,591
'confirmRename',592
detail: '${e.filename} → $name',593
)) {594
await act(595
() => service!.rename(file, childPath(path, name)),596
);597
}598
}599
},600
itemBuilder: (ctx) => [601
if (attrs.isFile) ...[602
PopupMenuItem(value: 'open', child: Text(ctx.tr('open'))),603
PopupMenuItem(604
value: 'saveToDevice',605
child: Text(ctx.tr('saveToDevice')),606
),607
PopupMenuItem(608
value: 'download',609
child: Text(ctx.tr('shareFile')),610
),611
PopupMenuItem(612
value: 'favorite',613
child: Text(614
ctx.tr(favorite ? 'removeFavorite' : 'addFavorite'),615
),616
),617
],618
PopupMenuItem(value: 'rename', child: Text(ctx.tr('rename'))),619
PopupMenuItem(value: 'delete', child: Text(ctx.tr('delete'))),620
],621
),622
);623
}625
static IconData _icon(FileKind k) => switch (k) {626
FileKind.image || FileKind.svg => Icons.image_outlined,627
FileKind.pdf => Icons.picture_as_pdf_outlined,628
FileKind.office => Icons.description_outlined,629
FileKind.archive => Icons.archive_outlined,630
FileKind.audio => Icons.audiotrack_outlined,631
FileKind.video => Icons.movie_outlined,632
FileKind.certificate => Icons.verified_user_outlined,633
FileKind.notebook => Icons.data_object,634
_ => Icons.insert_drive_file_outlined,635
};637
Widget _deviceView(BuildContext context) => LayoutBuilder(638
builder: (context, constraints) => ListView(639
padding: readablePadding(constraints, maxWidth: 900, gutter: 12),640
children: [641
Text(642
context.tr('deviceFilesNote'),643
style: Theme.of(context).textTheme.bodySmall,644
),645
const SizedBox(height: 8),646
Wrap(647
spacing: 8,648
runSpacing: 8,649
children: [650
FilledButton.icon(651
onPressed: pickFiles,652
icon: const Icon(Icons.file_open_outlined),653
label: Text(context.tr('openFiles')),654
),655
OutlinedButton.icon(656
onPressed: pickFolder,657
icon: const Icon(Icons.folder_open_outlined),658
label: Text(context.tr('openFolder')),659
),660
OutlinedButton.icon(661
onPressed: newDeviceFile,662
icon: const Icon(Icons.note_add_outlined),663
label: Text(context.tr('newFile')),664
),665
],666
),667
if (folders.isNotEmpty) ...[668
const SizedBox(height: 16),669
Row(670
children: [671
if (folders.length > 1)672
IconButton(673
tooltip: context.tr('parent'),674
onPressed: () async {675
folders.removeLast();676
await act(_listFolder, reload: false);677
},678
icon: const Icon(Icons.arrow_upward),679
),680
Expanded(681
child: Text(682
folders.map((f) => f.name).join(' / '),683
style: Theme.of(context).textTheme.titleSmall,684
),685
),686
],687
),688
if (folderEntries.isEmpty)689
Padding(690
padding: const EdgeInsets.all(16),691
child: Text(context.tr('emptyDirectory')),692
),693
for (final d in folderEntries) _deviceTile(context, d),694
],695
if (picked.isNotEmpty) ...[696
const SizedBox(height: 16),697
Text(698
context.tr('openedFiles'),699
style: Theme.of(context).textTheme.titleSmall,700
),701
for (final d in picked) _deviceTile(context, d),702
],703
],704
),705
);707
Widget _deviceTile(BuildContext context, DeviceDocument d) {708
final source = DeviceSource(c.documents, d);709
final favorite = !d.directory && recents.isFavorite(source.key);710
return ListTile(711
contentPadding: EdgeInsets.zero,712
leading: Icon(713
d.directory ? Icons.folder_outlined : _icon(fileKind(d.name)),714
),715
title: Text(d.name),716
subtitle: Text(717
[718
if (d.size != null && !d.directory) '${d.size} B',719
if (!d.writable && !d.directory) context.tr('readOnly'),720
].join(' · '),721
),722
onTap: d.directory723
? () async {724
folders.add(d);725
await act(_listFolder, reload: false);726
}727
: () => _open(source),728
trailing: d.directory729
? null730
: PopupMenuButton<String>(731
onSelected: (a) async {732
switch (a) {733
case 'open':734
await _open(source);735
case 'upload':736
await uploadDocument(d);737
case 'favorite':738
await toggleFavorite(source);739
}740
},741
itemBuilder: (ctx) => [742
PopupMenuItem(value: 'open', child: Text(ctx.tr('open'))),743
PopupMenuItem(744
value: 'upload',745
enabled: c.ssh.connected,746
child: Text(ctx.tr('uploadToServer')),747
),748
PopupMenuItem(749
value: 'favorite',750
child: Text(751
ctx.tr(favorite ? 'removeFavorite' : 'addFavorite'),752
),753
),754
],755
),756
);757
}759
Widget _recentView(BuildContext context, {required bool favorites}) {760
final list = favorites ? recents.favorites : recents.recent;761
if (list.isEmpty) {762
return PageMessage(763
favorites ? 'noFavorites' : 'noRecentFiles',764
icon: favorites ? Icons.star_outline : Icons.history,765
);766
}767
return ListView.builder(768
itemCount: list.length,769
itemBuilder: (context, i) {770
final r = list[i];771
return ListTile(772
leading: Icon(773
r.kind == 'remote' ? Icons.dns_outlined : Icons.phone_iphone,774
),775
title: Text(r.name),776
subtitle: Text(777
r.location,778
maxLines: 1,779
overflow: TextOverflow.ellipsis,780
),781
onTap: () => openRecent(r),782
trailing: IconButton(783
tooltip: context.tr(784
favorites ? 'removeFavorite' : 'removeFromList',785
),786
icon: Icon(favorites ? Icons.star : Icons.close),787
onPressed: () => act(() async {788
if (favorites) {789
await recents.setFavorite(r, false);790
} else {791
await recents.remove(r.key);792
}793
if (mounted) setState(() {});794
}, reload: false),795
),796
);797
},798
);799
}800
}SHA-256
4b43bcda52d09fb4e451ed0570e602dc8445e399343c64fe597227c21551febeCH-J Proprietary Software License 1.14 · Zdroje jsou zveřejněny pod CH-J Proprietary Software License 1.14. Jejich dostupnost nemění licenční podmínky ani neposkytuje další oprávnění.
SHA-256 archivu
46e427a43e87af091004f36145590bda8fe90086dfb0f631afeb5029fb0b5655