This commit is contained in:
royalcat 2024-04-24 20:36:33 +03:00
parent 5591f145a9
commit d8ee8a3a24
166 changed files with 15431 additions and 889 deletions

47
ui/lib/api/client.dart Normal file
View 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();

View 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
}
}

File diff suppressed because it is too large Load diff

138
ui/lib/api/schema.graphql Normal file
View 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
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
mutation MarkTorrentDownload($infohash: String!) {
downloadTorrent(infohash: $infohash) {
task {
id
}
}
}
query ListTorrents {
torrents {
name
infohash
bytesCompleted
bytesMissing
}
}

File diff suppressed because it is too large Load diff

View 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}%"),
],
),
);
}
}

View 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
View 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],
);
}
}

View 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),
)
],
),
);
},
);
},
);
}
}

View 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
View 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]}";
}
}