update
This commit is contained in:
parent
5591f145a9
commit
d8ee8a3a24
166 changed files with 15431 additions and 889 deletions
47
ui/lib/api/client.dart
Normal file
47
ui/lib/api/client.dart
Normal file
|
@ -0,0 +1,47 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:graphql/client.dart';
|
||||
|
||||
final client = GraphQLClient(
|
||||
link: _loggerLink.concat(HttpLink("http://localhost:4444/graphql")),
|
||||
cache: GraphQLCache(store: null),
|
||||
defaultPolicies: DefaultPolicies(
|
||||
query: Policies(
|
||||
fetch: FetchPolicy.noCache,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// final client = GraphQLClient(
|
||||
// link: HttpLink("http://192.168.217.150:4444/graphql"),
|
||||
// cache: GraphQLCache(store: null),
|
||||
// defaultPolicies: DefaultPolicies(
|
||||
// query: Policies(
|
||||
// fetch: FetchPolicy.noCache,
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
|
||||
class LoggerLink extends Link {
|
||||
@override
|
||||
Stream<Response> request(
|
||||
Request request, [
|
||||
NextLink? forward,
|
||||
]) {
|
||||
Stream<Response> response = forward!(request).map((Response fetchResult) {
|
||||
final ioStreamedResponse = fetchResult.context.entry<HttpLinkResponseContext>();
|
||||
if (kDebugMode) {
|
||||
print("Request: ${request.toString()}");
|
||||
print("Response:${ioStreamedResponse?.toString() ?? "null"}");
|
||||
}
|
||||
return fetchResult;
|
||||
}).handleError((error) {
|
||||
throw error;
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
LoggerLink();
|
||||
}
|
||||
|
||||
final _loggerLink = LoggerLink();
|
45
ui/lib/api/fs_entry.graphql
Normal file
45
ui/lib/api/fs_entry.graphql
Normal file
|
@ -0,0 +1,45 @@
|
|||
fragment File on File {
|
||||
name
|
||||
size
|
||||
}
|
||||
|
||||
fragment TorrentDir on TorrentFS {
|
||||
name
|
||||
torrent {
|
||||
name
|
||||
infohash
|
||||
bytesCompleted
|
||||
torrentFilePath
|
||||
bytesMissing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fragment ArchiveDir on ArchiveFS {
|
||||
name
|
||||
size
|
||||
}
|
||||
|
||||
fragment DirEntry on FsEntry {
|
||||
name
|
||||
|
||||
...TorrentDir
|
||||
...ArchiveDir
|
||||
...File
|
||||
}
|
||||
|
||||
|
||||
query ListDir($path: String!) {
|
||||
fsEntry(path: $path) {
|
||||
name
|
||||
|
||||
... on Dir {
|
||||
entries {
|
||||
...DirEntry
|
||||
}
|
||||
}
|
||||
|
||||
...TorrentDir
|
||||
...ArchiveDir
|
||||
}
|
||||
}
|
4627
ui/lib/api/fs_entry.graphql.dart
Normal file
4627
ui/lib/api/fs_entry.graphql.dart
Normal file
File diff suppressed because it is too large
Load diff
138
ui/lib/api/schema.graphql
Normal file
138
ui/lib/api/schema.graphql
Normal file
|
@ -0,0 +1,138 @@
|
|||
directive @oneOf on INPUT_OBJECT | FIELD_DEFINITION
|
||||
directive @stream on FIELD_DEFINITION
|
||||
type ArchiveFS implements Dir & FsEntry {
|
||||
name: String!
|
||||
entries: [FsEntry!]!
|
||||
size: Int!
|
||||
}
|
||||
input BooleanFilter @oneOf {
|
||||
eq: Boolean
|
||||
}
|
||||
type CleanupResponse {
|
||||
count: Int!
|
||||
list: [String!]!
|
||||
}
|
||||
scalar DateTime
|
||||
input DateTimeFilter @oneOf {
|
||||
eq: DateTime
|
||||
gt: DateTime
|
||||
lt: DateTime
|
||||
gte: DateTime
|
||||
lte: DateTime
|
||||
}
|
||||
interface Dir implements FsEntry {
|
||||
name: String!
|
||||
entries: [FsEntry!]!
|
||||
}
|
||||
type DownloadTorrentResponse {
|
||||
task: Task
|
||||
}
|
||||
interface File implements FsEntry {
|
||||
name: String!
|
||||
size: Int!
|
||||
}
|
||||
interface FsEntry {
|
||||
name: String!
|
||||
}
|
||||
input IntFilter @oneOf {
|
||||
eq: Int
|
||||
gt: Int
|
||||
lt: Int
|
||||
gte: Int
|
||||
lte: Int
|
||||
in: [Int!]
|
||||
}
|
||||
type Mutation {
|
||||
validateTorrents(filter: TorrentFilter!): Boolean!
|
||||
cleanupTorrents(files: Boolean, dryRun: Boolean!): CleanupResponse!
|
||||
downloadTorrent(infohash: String!, file: String): DownloadTorrentResponse
|
||||
dedupeStorage: Int!
|
||||
}
|
||||
input Pagination {
|
||||
offset: Int!
|
||||
limit: Int!
|
||||
}
|
||||
interface Progress {
|
||||
current: Int!
|
||||
total: Int!
|
||||
}
|
||||
type Query {
|
||||
torrents(filter: TorrentsFilter, pagination: Pagination): [Torrent!]!
|
||||
fsEntry(path: String!): FsEntry
|
||||
}
|
||||
type ResolverFS implements Dir & FsEntry {
|
||||
name: String!
|
||||
entries: [FsEntry!]!
|
||||
}
|
||||
type Schema {
|
||||
query: Query
|
||||
mutation: Mutation
|
||||
}
|
||||
type SimpleDir implements Dir & FsEntry {
|
||||
name: String!
|
||||
entries: [FsEntry!]!
|
||||
}
|
||||
type SimpleFile implements File & FsEntry {
|
||||
name: String!
|
||||
size: Int!
|
||||
}
|
||||
input StringFilter @oneOf {
|
||||
eq: String
|
||||
substr: String
|
||||
in: [String!]
|
||||
}
|
||||
type Subscription {
|
||||
taskProgress(taskID: ID!): Progress
|
||||
torrentDownloadUpdates: TorrentProgress
|
||||
}
|
||||
type Task {
|
||||
id: ID!
|
||||
}
|
||||
type Torrent {
|
||||
name: String!
|
||||
infohash: String!
|
||||
bytesCompleted: Int!
|
||||
torrentFilePath: String!
|
||||
bytesMissing: Int!
|
||||
files: [TorrentFile!]!
|
||||
excludedFiles: [TorrentFile!]!
|
||||
peers: [TorrentPeer!]!
|
||||
}
|
||||
type TorrentFS implements Dir & FsEntry {
|
||||
name: String!
|
||||
torrent: Torrent!
|
||||
entries: [FsEntry!]!
|
||||
}
|
||||
type TorrentFile {
|
||||
filename: String!
|
||||
size: Int!
|
||||
bytesCompleted: Int!
|
||||
}
|
||||
type TorrentFileEntry implements File & FsEntry {
|
||||
name: String!
|
||||
torrent: Torrent!
|
||||
size: Int!
|
||||
}
|
||||
input TorrentFilter @oneOf {
|
||||
everything: Boolean
|
||||
infohash: String
|
||||
}
|
||||
type TorrentPeer {
|
||||
ip: String!
|
||||
downloadRate: Float!
|
||||
discovery: String!
|
||||
port: Int!
|
||||
clientName: String!
|
||||
}
|
||||
type TorrentProgress implements Progress {
|
||||
torrent: Torrent!
|
||||
current: Int!
|
||||
total: Int!
|
||||
}
|
||||
input TorrentsFilter {
|
||||
infohash: StringFilter
|
||||
name: StringFilter
|
||||
bytesCompleted: IntFilter
|
||||
bytesMissing: IntFilter
|
||||
peersCount: IntFilter
|
||||
}
|
1514
ui/lib/api/schema.graphql.dart
Normal file
1514
ui/lib/api/schema.graphql.dart
Normal file
File diff suppressed because it is too large
Load diff
16
ui/lib/api/torrent.graphql
Normal file
16
ui/lib/api/torrent.graphql
Normal file
|
@ -0,0 +1,16 @@
|
|||
mutation MarkTorrentDownload($infohash: String!) {
|
||||
downloadTorrent(infohash: $infohash) {
|
||||
task {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query ListTorrents {
|
||||
torrents {
|
||||
name
|
||||
infohash
|
||||
bytesCompleted
|
||||
bytesMissing
|
||||
}
|
||||
}
|
1323
ui/lib/api/torrent.graphql.dart
Normal file
1323
ui/lib/api/torrent.graphql.dart
Normal file
File diff suppressed because it is too large
Load diff
25
ui/lib/components/download.dart
Normal file
25
ui/lib/components/download.dart
Normal file
|
@ -0,0 +1,25 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:tstor_ui/utils/bytes.dart';
|
||||
|
||||
class DownloadProgress extends StatelessWidget {
|
||||
final int current;
|
||||
final int total;
|
||||
|
||||
const DownloadProgress(this.current, this.total, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: Row(
|
||||
children: [
|
||||
Text("${current.bytesFormat()}/${total.bytesFormat()}"),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: LinearProgressIndicator(value: current / total)),
|
||||
const SizedBox(width: 10),
|
||||
Text("${current / total * 100}%"),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
26
ui/lib/font/t_icons_icons.dart
Normal file
26
ui/lib/font/t_icons_icons.dart
Normal file
|
@ -0,0 +1,26 @@
|
|||
/// Flutter icons TIcons
|
||||
/// Copyright (C) 2024 by original authors @ fluttericon.com, fontello.com
|
||||
/// This font was generated by FlutterIcon.com, which is derived from Fontello.
|
||||
///
|
||||
/// To use this font, place it in your fonts/ directory and include the
|
||||
/// following in your pubspec.yaml
|
||||
///
|
||||
/// flutter:
|
||||
/// fonts:
|
||||
/// - family: TIcons
|
||||
/// fonts:
|
||||
/// - asset: fonts/TIcons.ttf
|
||||
///
|
||||
///
|
||||
///
|
||||
library;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TIcons {
|
||||
TIcons._();
|
||||
|
||||
static const _kFontFam = 'TIcons';
|
||||
static const String? _kFontPkg = null;
|
||||
|
||||
static const IconData bittorrent_bttold_logo = IconData(0xe801, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
}
|
196
ui/lib/main.dart
Normal file
196
ui/lib/main.dart
Normal file
|
@ -0,0 +1,196 @@
|
|||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:graphql_flutter/graphql_flutter.dart';
|
||||
import 'package:tstor_ui/api/client.dart';
|
||||
import 'package:tstor_ui/screens/downloads.dart';
|
||||
import 'package:tstor_ui/screens/file_view.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
// This widget is the root of your application.
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DynamicColorBuilder(builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
|
||||
return MaterialApp(
|
||||
title: 'tStor',
|
||||
theme: lightDynamic != null ? ThemeData.from(colorScheme: lightDynamic) : ThemeData.light(),
|
||||
darkTheme:
|
||||
darkDynamic != null ? ThemeData.from(colorScheme: darkDynamic) : ThemeData.dark(),
|
||||
home: GraphQLProvider(
|
||||
client: ValueNotifier(client),
|
||||
child: const MyHomePage(),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({super.key});
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int currentPageIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("tStor"),
|
||||
),
|
||||
body: <Widget>[
|
||||
const FileViewScreen(),
|
||||
const DownloadsScreen(),
|
||||
][currentPageIndex],
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: currentPageIndex,
|
||||
onTap: (i) => setState(() {
|
||||
currentPageIndex = i;
|
||||
}),
|
||||
items: const <BottomNavigationBarItem>[
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.folder_copy_outlined),
|
||||
activeIcon: Icon(Icons.folder_copy),
|
||||
label: 'Files',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.download_outlined),
|
||||
activeIcon: Icon(Icons.download),
|
||||
label: 'Downloads',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NavigationExample extends StatefulWidget {
|
||||
const NavigationExample({super.key});
|
||||
|
||||
@override
|
||||
State<NavigationExample> createState() => _NavigationExampleState();
|
||||
}
|
||||
|
||||
class _NavigationExampleState extends State<NavigationExample> {
|
||||
int currentPageIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ThemeData theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
bottomNavigationBar: NavigationBar(
|
||||
onDestinationSelected: (int index) {
|
||||
setState(() {
|
||||
currentPageIndex = index;
|
||||
});
|
||||
},
|
||||
indicatorColor: Colors.amber,
|
||||
selectedIndex: currentPageIndex,
|
||||
destinations: const <Widget>[
|
||||
NavigationDestination(
|
||||
selectedIcon: Icon(Icons.home),
|
||||
icon: Icon(Icons.home_outlined),
|
||||
label: 'Home',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(child: Icon(Icons.notifications_sharp)),
|
||||
label: 'Notifications',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
label: Text('2'),
|
||||
child: Icon(Icons.messenger_sharp),
|
||||
),
|
||||
label: 'Messages',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: <Widget>[
|
||||
/// Home page
|
||||
Card(
|
||||
shadowColor: Colors.transparent,
|
||||
margin: const EdgeInsets.all(8.0),
|
||||
child: SizedBox.expand(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Home page',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
/// Notifications page
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.notifications_sharp),
|
||||
title: Text('Notification 1'),
|
||||
subtitle: Text('This is a notification'),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.notifications_sharp),
|
||||
title: Text('Notification 2'),
|
||||
subtitle: Text('This is a notification'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
/// Messages page
|
||||
ListView.builder(
|
||||
reverse: true,
|
||||
itemCount: 2,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
if (index == 0) {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(8.0),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: Text(
|
||||
'Hello',
|
||||
style: theme.textTheme.bodyLarge!.copyWith(color: theme.colorScheme.onPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(8.0),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: Text(
|
||||
'Hi!',
|
||||
style: theme.textTheme.bodyLarge!.copyWith(color: theme.colorScheme.onPrimary),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
][currentPageIndex],
|
||||
);
|
||||
}
|
||||
}
|
55
ui/lib/screens/downloads.dart
Normal file
55
ui/lib/screens/downloads.dart
Normal file
|
@ -0,0 +1,55 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:tstor_ui/api/client.dart';
|
||||
import 'package:tstor_ui/api/torrent.graphql.dart';
|
||||
import 'package:tstor_ui/components/download.dart';
|
||||
|
||||
class DownloadsScreen extends StatefulWidget {
|
||||
const DownloadsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DownloadsScreen> createState() => _DownloadsScreenState();
|
||||
}
|
||||
|
||||
class _DownloadsScreenState extends State<DownloadsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: client.query$ListTorrents(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final torrents = snapshot.data!.parsedData!.torrents;
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: torrents.length,
|
||||
itemBuilder: (context, index) {
|
||||
final torrent = torrents[index];
|
||||
return ListTile(
|
||||
title: Text(torrent.name),
|
||||
subtitle: DownloadProgress(
|
||||
torrent.bytesCompleted, torrent.bytesCompleted + torrent.bytesMissing),
|
||||
trailing: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => client.mutate$MarkTorrentDownload(
|
||||
Options$Mutation$MarkTorrentDownload(
|
||||
variables: Variables$Mutation$MarkTorrentDownload(
|
||||
infohash: torrent.infohash,
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.download),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
352
ui/lib/screens/file_view.dart
Normal file
352
ui/lib/screens/file_view.dart
Normal file
|
@ -0,0 +1,352 @@
|
|||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
30
ui/lib/utils/bytes.dart
Normal file
30
ui/lib/utils/bytes.dart
Normal file
|
@ -0,0 +1,30 @@
|
|||
extension BytesFormat on num {
|
||||
/// method returns a human readable string representing a file size
|
||||
/// size can be passed as number or as string
|
||||
/// the optional parameter 'round' specifies the number of numbers after comma/point (default is 2)
|
||||
/// the optional boolean parameter 'useBase1024' specifies if we should count in 1024's (true) or 1000's (false). e.g. 1KB = 1024B (default is true)
|
||||
String bytesFormat({int round = 2, bool useBase1024 = true}) {
|
||||
const List<String> affixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
num divider = useBase1024 ? 1024 : 1000;
|
||||
|
||||
num size = this;
|
||||
num runningDivider = divider;
|
||||
num runningPreviousDivider = 0;
|
||||
int affix = 0;
|
||||
|
||||
while (size >= runningDivider && affix < affixes.length - 1) {
|
||||
runningPreviousDivider = runningDivider;
|
||||
runningDivider *= divider;
|
||||
affix++;
|
||||
}
|
||||
|
||||
String result =
|
||||
(runningPreviousDivider == 0 ? size : size / runningPreviousDivider).toStringAsFixed(round);
|
||||
|
||||
//Check if the result ends with .00000 (depending on how many decimals) and remove it if found.
|
||||
if (result.endsWith("0" * round)) result = result.substring(0, result.length - round - 1);
|
||||
|
||||
return "$result ${affixes[affix]}";
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue