353 lines
9.5 KiB
Dart
353 lines
9.5 KiB
Dart
|
import 'package:flutter/material.dart';
|
||
|
import 'package:graphql/client.dart';
|
||
|
import 'package:graphql_flutter/graphql_flutter.dart';
|
||
|
import 'package:tstor_ui/api/client.dart';
|
||
|
import 'package:tstor_ui/api/fs_entry.graphql.dart';
|
||
|
import 'package:tstor_ui/api/torrent.graphql.dart';
|
||
|
import 'package:tstor_ui/components/download.dart';
|
||
|
|
||
|
import 'package:tstor_ui/font/t_icons_icons.dart';
|
||
|
import 'package:path/path.dart' as p;
|
||
|
import 'package:tstor_ui/utils/bytes.dart';
|
||
|
|
||
|
class FileViewScreen extends StatefulWidget {
|
||
|
final String initialPath;
|
||
|
|
||
|
const FileViewScreen({super.key, this.initialPath = "/"});
|
||
|
|
||
|
@override
|
||
|
State<FileViewScreen> createState() => _FileViewScreenState();
|
||
|
}
|
||
|
|
||
|
class _FileViewScreenState extends State<FileViewScreen> {
|
||
|
late String path;
|
||
|
|
||
|
late final TextEditingController pathController;
|
||
|
|
||
|
@override
|
||
|
void initState() {
|
||
|
path = widget.initialPath;
|
||
|
pathController = TextEditingController(text: path);
|
||
|
listDirFuture = client.query$ListDir(
|
||
|
Options$Query$ListDir(
|
||
|
variables: Variables$Query$ListDir(path: path),
|
||
|
),
|
||
|
);
|
||
|
super.initState();
|
||
|
}
|
||
|
|
||
|
void cd(String part) {
|
||
|
setState(() {
|
||
|
listDirFuture = null;
|
||
|
});
|
||
|
|
||
|
setState(() {
|
||
|
path = p.normalize(p.join(path, part));
|
||
|
pathController.text = path;
|
||
|
listDirFuture = client.query$ListDir(
|
||
|
Options$Query$ListDir(
|
||
|
variables: Variables$Query$ListDir(path: path),
|
||
|
),
|
||
|
);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
void refresh() {
|
||
|
setState(() {
|
||
|
listDirFuture = null;
|
||
|
});
|
||
|
|
||
|
setState(() {
|
||
|
listDirFuture = client.query$ListDir(
|
||
|
Options$Query$ListDir(
|
||
|
variables: Variables$Query$ListDir(path: path),
|
||
|
fetchPolicy: FetchPolicy.noCache,
|
||
|
),
|
||
|
);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
Future<QueryResult<Query$ListDir>>? listDirFuture;
|
||
|
|
||
|
@override
|
||
|
Widget build(BuildContext context) {
|
||
|
return Scaffold(
|
||
|
appBar: AppBar(
|
||
|
title: TextField(
|
||
|
controller: pathController,
|
||
|
onEditingComplete: () => cd(pathController.text),
|
||
|
),
|
||
|
leading: IconButton(
|
||
|
onPressed: () => cd(".."),
|
||
|
icon: const Icon(Icons.arrow_upward),
|
||
|
),
|
||
|
actions: [
|
||
|
IconButton(
|
||
|
icon: const Icon(Icons.refresh),
|
||
|
onPressed: refresh,
|
||
|
)
|
||
|
],
|
||
|
),
|
||
|
body: FutureBuilder(
|
||
|
key: GlobalKey(),
|
||
|
future: listDirFuture,
|
||
|
initialData: null,
|
||
|
builder: (context, snapshot) {
|
||
|
if (!snapshot.hasData) {
|
||
|
return const Center(child: CircularProgressIndicator());
|
||
|
}
|
||
|
|
||
|
final data = snapshot.data!;
|
||
|
|
||
|
if (data.exception != null) {
|
||
|
return Text("Error\n${data.exception.toString()}");
|
||
|
}
|
||
|
|
||
|
final entry = snapshot.data?.parsedData?.fsEntry;
|
||
|
if (entry == null) {
|
||
|
return const Center(child: Text("Entry not exists"));
|
||
|
}
|
||
|
|
||
|
final entries = _getEntries(entry);
|
||
|
|
||
|
if (entries == null || entries.isEmpty) {
|
||
|
return const Center(child: Text("Empty dir"));
|
||
|
}
|
||
|
|
||
|
return CustomScrollView(
|
||
|
slivers: [
|
||
|
EntryInfoSliver(entry: entry),
|
||
|
SliverList.builder(
|
||
|
itemCount: entries.length,
|
||
|
itemBuilder: (context, index) {
|
||
|
return DirEntry(
|
||
|
entry: entries[index],
|
||
|
onTap: (name, isFile) {
|
||
|
if (!isFile) {
|
||
|
cd(name);
|
||
|
}
|
||
|
},
|
||
|
);
|
||
|
},
|
||
|
),
|
||
|
],
|
||
|
);
|
||
|
},
|
||
|
),
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
List<Fragment$DirEntry>? _getEntries(Query$ListDir$fsEntry entry) {
|
||
|
switch (entry) {
|
||
|
case Query$ListDir$fsEntry$$ArchiveFS entry:
|
||
|
return entry.entries;
|
||
|
case Query$ListDir$fsEntry$$ResolverFS entry:
|
||
|
return entry.entries;
|
||
|
case Query$ListDir$fsEntry$$SimpleDir entry:
|
||
|
return entry.entries;
|
||
|
case Query$ListDir$fsEntry$$TorrentFS entry:
|
||
|
return entry.entries;
|
||
|
default:
|
||
|
return null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class DirEntry extends StatelessWidget {
|
||
|
final Fragment$DirEntry entry;
|
||
|
final void Function(String name, bool isFile) onTap;
|
||
|
|
||
|
const DirEntry({super.key, required this.entry, required this.onTap});
|
||
|
|
||
|
@override
|
||
|
Widget build(BuildContext context) {
|
||
|
switch (entry) {
|
||
|
case Fragment$TorrentDir entry:
|
||
|
final completness = entry.torrent.bytesCompleted /
|
||
|
(entry.torrent.bytesCompleted + entry.torrent.bytesMissing);
|
||
|
|
||
|
return ListTile(
|
||
|
leading: const Icon(TIcons.bittorrent_bttold_logo),
|
||
|
title: Text(entry.name),
|
||
|
subtitle: Row(
|
||
|
mainAxisSize: MainAxisSize.max,
|
||
|
children: [
|
||
|
Text("${completness * 100}%"),
|
||
|
Expanded(child: LinearProgressIndicator(value: completness)),
|
||
|
IconButton(
|
||
|
onPressed: () => client.mutate$MarkTorrentDownload(
|
||
|
Options$Mutation$MarkTorrentDownload(
|
||
|
variables:
|
||
|
Variables$Mutation$MarkTorrentDownload(infohash: entry.torrent.infohash),
|
||
|
),
|
||
|
),
|
||
|
icon: const Icon(Icons.download),
|
||
|
)
|
||
|
],
|
||
|
),
|
||
|
onTap: () => onTap(entry.name, false),
|
||
|
);
|
||
|
case Fragment$DirEntry$$SimpleDir entry:
|
||
|
return ListTile(
|
||
|
leading: const Icon(Icons.folder),
|
||
|
title: Text(entry.name),
|
||
|
onTap: () => onTap(entry.name, false),
|
||
|
);
|
||
|
case Fragment$DirEntry$$ArchiveFS entry:
|
||
|
return ListTile(
|
||
|
leading: const Icon(Icons.folder_zip),
|
||
|
title: Text(entry.name),
|
||
|
onTap: () => onTap(entry.name, false),
|
||
|
);
|
||
|
case Fragment$File entry:
|
||
|
return ListTile(
|
||
|
leading: const Icon(Icons.insert_drive_file),
|
||
|
title: Text(entry.name),
|
||
|
onTap: () => onTap(entry.name, true),
|
||
|
);
|
||
|
default:
|
||
|
return ListTile(
|
||
|
leading: const Icon(Icons.question_mark),
|
||
|
title: Text(entry.name),
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class EntryInfoSliver extends StatelessWidget {
|
||
|
final Query$ListDir$fsEntry entry;
|
||
|
|
||
|
const EntryInfoSliver({super.key, required this.entry});
|
||
|
|
||
|
@override
|
||
|
Widget build(BuildContext context) {
|
||
|
switch (entry) {
|
||
|
case Query$ListDir$fsEntry$$TorrentFS entry:
|
||
|
final total = entry.torrent.bytesCompleted + entry.torrent.bytesMissing;
|
||
|
|
||
|
return EntryInfoHeader(
|
||
|
icon: TIcons.bittorrent_bttold_logo,
|
||
|
title: Text(entry.torrent.name),
|
||
|
body: Column(
|
||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||
|
children: [
|
||
|
Text(entry.torrent.name),
|
||
|
Text("Size: ${total.bytesFormat()}"),
|
||
|
Text("InfoHash: ${entry.torrent.infohash}"),
|
||
|
DownloadProgress(entry.torrent.bytesCompleted, total)
|
||
|
],
|
||
|
),
|
||
|
actions: [
|
||
|
IconButton(
|
||
|
icon: const Icon(Icons.download),
|
||
|
onPressed: () => client.mutate$MarkTorrentDownload(
|
||
|
Options$Mutation$MarkTorrentDownload(
|
||
|
variables: Variables$Mutation$MarkTorrentDownload(
|
||
|
infohash: entry.torrent.infohash,
|
||
|
),
|
||
|
),
|
||
|
),
|
||
|
)
|
||
|
],
|
||
|
);
|
||
|
|
||
|
default:
|
||
|
return EntryInfoHeader(
|
||
|
icon: Icons.folder,
|
||
|
title: Text(entry.name),
|
||
|
body: Text(entry.name),
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class EntryInfoHeader extends StatelessWidget {
|
||
|
final IconData icon;
|
||
|
final Widget title;
|
||
|
final Widget body;
|
||
|
final List<Widget>? actions;
|
||
|
|
||
|
const EntryInfoHeader({
|
||
|
super.key,
|
||
|
required this.icon,
|
||
|
required this.title,
|
||
|
required this.body,
|
||
|
this.actions,
|
||
|
});
|
||
|
|
||
|
@override
|
||
|
Widget build(BuildContext context) {
|
||
|
return SliverPersistentHeader(
|
||
|
floating: true,
|
||
|
pinned: false,
|
||
|
delegate: EntryInfoSliverHeaderDelegate(icon: icon, title: title, body: body),
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class EntryInfoSliverHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||
|
final IconData icon;
|
||
|
final Widget title;
|
||
|
final Widget body;
|
||
|
final List<Widget>? actions;
|
||
|
final double size;
|
||
|
|
||
|
const EntryInfoSliverHeaderDelegate({
|
||
|
required this.icon,
|
||
|
required this.title,
|
||
|
required this.body,
|
||
|
this.actions,
|
||
|
this.size = 150,
|
||
|
});
|
||
|
|
||
|
@override
|
||
|
double get maxExtent => size;
|
||
|
|
||
|
@override
|
||
|
double get minExtent => size;
|
||
|
|
||
|
@override
|
||
|
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => true;
|
||
|
|
||
|
@override
|
||
|
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
|
||
|
final content = [
|
||
|
Icon(icon, size: 50),
|
||
|
Expanded(child: body),
|
||
|
];
|
||
|
|
||
|
if (actions != null && actions!.isNotEmpty) {
|
||
|
content.add(ButtonBar(children: actions!));
|
||
|
}
|
||
|
|
||
|
final appBarTheme = AppBarTheme.of(context);
|
||
|
final colorScheme = Theme.of(context).colorScheme;
|
||
|
final onTop = (shrinkOffset == 0);
|
||
|
|
||
|
return Material(
|
||
|
color:
|
||
|
onTop ? appBarTheme.backgroundColor ?? colorScheme.surface : colorScheme.surfaceContainer,
|
||
|
elevation: onTop ? 0 : appBarTheme.elevation ?? 3,
|
||
|
surfaceTintColor: appBarTheme.surfaceTintColor ?? colorScheme.surfaceTint,
|
||
|
child: ClipRect(
|
||
|
child: SizedBox(
|
||
|
height: maxExtent,
|
||
|
child: Column(
|
||
|
children: [
|
||
|
const Spacer(),
|
||
|
Row(
|
||
|
children: content,
|
||
|
),
|
||
|
const Spacer(),
|
||
|
const Divider(
|
||
|
height: 1,
|
||
|
thickness: 1,
|
||
|
),
|
||
|
],
|
||
|
),
|
||
|
),
|
||
|
),
|
||
|
);
|
||
|
}
|
||
|
}
|