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 createState() => _FileViewScreenState(); } class _FileViewScreenState extends State { 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>? 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? _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? 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? 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, ), ], ), ), ), ); } }