├── .envrc ├── linux ├── .gitignore ├── main.cc ├── flutter │ ├── generated_plugin_registrant.h │ ├── generated_plugins.cmake │ ├── generated_plugin_registrant.cc │ └── CMakeLists.txt ├── my_application.h ├── CMakeLists.txt └── my_application.cc ├── lib ├── utils │ ├── safe_divide.dart │ ├── capitalize_string.dart │ ├── timer_stream.dart │ ├── equal.dart │ ├── rect_custom_clipper.dart │ ├── generic_join.dart │ ├── multiselect_algo.dart │ ├── use_values_changed.dart │ ├── units.dart │ └── filter_unknown_arguments.dart ├── state │ ├── cli_args.dart │ ├── filters.dart │ ├── focused.dart │ ├── config.dart │ ├── torrents.dart │ └── transmission.dart ├── models │ ├── cli_args.dart │ ├── filters.dart │ ├── cli_args.g.dart │ ├── config.dart │ ├── config.g.dart │ ├── filters.g.dart │ ├── torrent.dart │ ├── cli_args.freezed.dart │ ├── filters.freezed.dart │ ├── torrent.g.dart │ └── config.freezed.dart ├── widgets │ ├── common │ │ ├── button.dart │ │ ├── smooth_scrolling.dart │ │ ├── side_popup.dart │ │ └── responsive_horizontal_grid.dart │ ├── torrent │ │ ├── torrent_overview │ │ │ ├── torrent_overview.dart │ │ │ ├── overview_info.dart │ │ │ └── overview_files.dart │ │ └── torrent.dart │ ├── smooth_graph │ │ ├── get_y_from_x.dart │ │ └── smooth_graph.dart │ └── side_view.dart └── main.dart ├── analysis_options.yaml ├── flake.nix ├── .gitignore ├── pubspec.yaml ├── nix └── package.nix ├── flake.lock ├── .metadata └── README.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake -------------------------------------------------------------------------------- /linux/.gitignore: -------------------------------------------------------------------------------- 1 | flutter/ephemeral 2 | -------------------------------------------------------------------------------- /lib/utils/safe_divide.dart: -------------------------------------------------------------------------------- 1 | double safeDivide(double res, [double def = 0]) => res.isFinite ? res : def; 2 | -------------------------------------------------------------------------------- /lib/utils/capitalize_string.dart: -------------------------------------------------------------------------------- 1 | String capitalizeString(String text) => text[0].toUpperCase() + text.substring(1); 2 | -------------------------------------------------------------------------------- /lib/utils/timer_stream.dart: -------------------------------------------------------------------------------- 1 | Stream timerStream(Duration duration, {bool firstTime = true}) async* { 2 | if (firstTime) yield 0; 3 | yield* Stream.periodic(duration); 4 | } 5 | -------------------------------------------------------------------------------- /linux/main.cc: -------------------------------------------------------------------------------- 1 | #include "my_application.h" 2 | 3 | int main(int argc, char** argv) { 4 | g_autoptr(MyApplication) app = my_application_new(); 5 | return g_application_run(G_APPLICATION(app), argc, argv); 6 | } 7 | -------------------------------------------------------------------------------- /lib/state/cli_args.dart: -------------------------------------------------------------------------------- 1 | import 'package:flarrent/models/cli_args.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | final cliArgsProvider = Provider((ref) => throw UnimplementedError()); 5 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:very_good_analysis/analysis_options.3.1.0.yaml 2 | analyzer: 3 | plugins: 4 | - custom_lint 5 | linter: 6 | rules: 7 | public_member_api_docs: false 8 | lines_longer_than_80_chars: false 9 | -------------------------------------------------------------------------------- /lib/state/filters.dart: -------------------------------------------------------------------------------- 1 | import 'package:flarrent/models/filters.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | final filtersProvider = StateProvider( 5 | (ref) => const Filters( 6 | query: '', 7 | states: [], 8 | sortBy: SortBy.addedOn, 9 | ascending: false, 10 | ), 11 | ); 12 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugin_registrant.h: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #ifndef GENERATED_PLUGIN_REGISTRANT_ 8 | #define GENERATED_PLUGIN_REGISTRANT_ 9 | 10 | #include 11 | 12 | // Registers Flutter plugins. 13 | void fl_register_plugins(FlPluginRegistry* registry); 14 | 15 | #endif // GENERATED_PLUGIN_REGISTRANT_ 16 | -------------------------------------------------------------------------------- /lib/state/focused.dart: -------------------------------------------------------------------------------- 1 | import 'package:flarrent/utils/timer_stream.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | import 'package:window_manager/window_manager.dart'; 4 | 5 | final focusedProvider = StreamProvider((ref) async* { 6 | await windowManager.ensureInitialized(); 7 | await for (final _ in timerStream(const Duration(seconds: 1))) { 8 | yield await windowManager.isFocused(); 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /lib/models/cli_args.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:freezed_annotation/freezed_annotation.dart'; 3 | 4 | part 'cli_args.freezed.dart'; 5 | part 'cli_args.g.dart'; 6 | 7 | @freezed 8 | class CliArgs with _$CliArgs { 9 | const factory CliArgs({ 10 | String? configLocation, 11 | List? torrentsLinks, 12 | }) = _CliArgs; 13 | 14 | factory CliArgs.fromJson(Map json) => _$CliArgsFromJson(json); 15 | } 16 | -------------------------------------------------------------------------------- /linux/my_application.h: -------------------------------------------------------------------------------- 1 | #include 2 | #ifndef FLUTTER_MY_APPLICATION_H_ 3 | #define FLUTTER_MY_APPLICATION_H_ 4 | 5 | #include 6 | 7 | G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, 8 | GtkApplication) 9 | 10 | /** 11 | * my_application_new: 12 | * 13 | * Creates a new Flutter-based application. 14 | * 15 | * Returns: a new #MyApplication. 16 | */ 17 | MyApplication* my_application_new(); 18 | 19 | #endif // FLUTTER_MY_APPLICATION_H_ 20 | -------------------------------------------------------------------------------- /lib/utils/equal.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: test_types_in_equals 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | @immutable 6 | class Equal { 7 | const Equal(this.value, this._equal, [this._hashCode]); 8 | final T value; 9 | final bool Function(T value, T other) _equal; 10 | final int Function(T value)? _hashCode; 11 | 12 | @override 13 | bool operator ==(Object other) => _equal(value, (other as Equal).value); 14 | 15 | @override 16 | int get hashCode => _hashCode?.call(value) ?? super.hashCode; 17 | } 18 | -------------------------------------------------------------------------------- /lib/models/filters.dart: -------------------------------------------------------------------------------- 1 | import 'package:flarrent/models/torrent.dart'; 2 | import 'package:flutter/foundation.dart'; 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'filters.freezed.dart'; 6 | part 'filters.g.dart'; 7 | 8 | enum SortBy { 9 | name, 10 | downloadSpeed, 11 | uploadSpeed, 12 | size, 13 | addedOn, 14 | completedOn, 15 | } 16 | 17 | @freezed 18 | class Filters with _$Filters { 19 | const factory Filters({ 20 | required String query, 21 | required List states, 22 | required SortBy sortBy, 23 | required bool ascending, 24 | }) = _Filters; 25 | 26 | factory Filters.fromJson(Map json) => _$FiltersFromJson(json); 27 | } 28 | -------------------------------------------------------------------------------- /lib/models/cli_args.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'cli_args.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_CliArgs _$$_CliArgsFromJson(Map json) => _$_CliArgs( 10 | configLocation: json['configLocation'] as String?, 11 | torrentsLinks: (json['torrentsLinks'] as List?) 12 | ?.map((e) => e as String) 13 | .toList(), 14 | ); 15 | 16 | Map _$$_CliArgsToJson(_$_CliArgs instance) => 17 | { 18 | 'configLocation': instance.configLocation, 19 | 'torrentsLinks': instance.torrentsLinks, 20 | }; 21 | -------------------------------------------------------------------------------- /lib/utils/rect_custom_clipper.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/rendering.dart'; 2 | 3 | class RRectCustomClipper extends CustomClipper { 4 | const RRectCustomClipper(RRect Function(Size size) getClip) 5 | : _getClip = getClip; 6 | 7 | final RRect Function(Size size) _getClip; 8 | 9 | @override 10 | RRect getClip(Size size) => _getClip(size); 11 | 12 | @override 13 | bool shouldReclip(covariant CustomClipper oldClipper) => 14 | oldClipper != this; 15 | } 16 | 17 | class RectCustomClipper extends CustomClipper { 18 | const RectCustomClipper(Rect Function(Size size) getClip) 19 | : _getClip = getClip; 20 | 21 | final Rect Function(Size size) _getClip; 22 | 23 | @override 24 | Rect getClip(Size size) => _getClip(size); 25 | 26 | @override 27 | bool shouldReclip(covariant CustomClipper oldClipper) => 28 | oldClipper != this; 29 | } 30 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Frontend torrent client"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = { 10 | self, 11 | flake-utils, 12 | nixpkgs, 13 | }: 14 | flake-utils.lib.eachDefaultSystem (system: let 15 | pkgs = import nixpkgs { 16 | inherit system; 17 | overlays = [ 18 | self.overlays.default 19 | ]; 20 | }; 21 | in { 22 | packages = { 23 | inherit (pkgs) flarrent; 24 | default = pkgs.flarrent; 25 | }; 26 | 27 | devShell = pkgs.mkShell { 28 | nativeBuildInputs = with pkgs; [pkg-config flutter327]; 29 | }; 30 | }) 31 | // { 32 | overlays.default = _final: prev: { 33 | flarrent = prev.callPackage ./nix/package.nix {}; 34 | }; 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /lib/utils/generic_join.dart: -------------------------------------------------------------------------------- 1 | // https://github.com/hauketoenjes/responsive_grid_list 2 | 3 | /// Generic Join extension for [List]s. 4 | extension GenericJoin on List { 5 | /// 6 | /// Extension which joins [separator] in between the items in [List]. 7 | /// 8 | /// Does the same as the [join()] method but instead of returning a string, 9 | /// returns the list with separators in between. Mainly used to join Widgets 10 | /// in between other widgets. 11 | /// 12 | /// See: https://api.dart.dev/stable/dart-core/Iterable/join.html 13 | /// 14 | List genericJoin(T separator) { 15 | final out = []; 16 | final iterator = this.iterator; 17 | 18 | if (!iterator.moveNext()) return out; 19 | 20 | out.add(iterator.current); 21 | while (iterator.moveNext()) { 22 | out 23 | ..add(separator) 24 | ..add(iterator.current); 25 | } 26 | return out; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | migrate_working_dir/ 12 | 13 | # IntelliJ related 14 | *.iml 15 | *.ipr 16 | *.iws 17 | .idea/ 18 | 19 | # The .vscode folder contains launch configuration and tasks you configure in 20 | # VS Code which you may wish to be included in version control, so this line 21 | # is commented out by default. 22 | #.vscode/ 23 | 24 | # Flutter/Dart/Pub related 25 | **/doc/api/ 26 | **/ios/Flutter/.last_build_id 27 | .dart_tool/ 28 | .flutter-plugins 29 | .flutter-plugins-dependencies 30 | .packages 31 | .pub-cache/ 32 | .pub/ 33 | /build/ 34 | 35 | # Symbolication related 36 | app.*.symbols 37 | 38 | # Obfuscation related 39 | app.*.map.json 40 | 41 | # Android Studio will place build artifacts here 42 | /android/app/debug 43 | /android/app/profile 44 | /android/app/release 45 | 46 | # Nix 47 | result 48 | .direnv -------------------------------------------------------------------------------- /linux/flutter/generated_plugins.cmake: -------------------------------------------------------------------------------- 1 | # 2 | # Generated file, do not edit. 3 | # 4 | 5 | list(APPEND FLUTTER_PLUGIN_LIST 6 | screen_retriever 7 | url_launcher_linux 8 | window_manager 9 | ) 10 | 11 | list(APPEND FLUTTER_FFI_PLUGIN_LIST 12 | ) 13 | 14 | set(PLUGIN_BUNDLED_LIBRARIES) 15 | 16 | foreach(plugin ${FLUTTER_PLUGIN_LIST}) 17 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) 18 | target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) 19 | list(APPEND PLUGIN_BUNDLED_LIBRARIES $) 20 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) 21 | endforeach(plugin) 22 | 23 | foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) 24 | add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) 25 | list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) 26 | endforeach(ffi_plugin) 27 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flarrent 2 | description: Torrent frontend for Transmission 3 | publish_to: 'none' 4 | version: 0.1.0 5 | 6 | environment: 7 | sdk: '>=3.0.0 <4.0.0' 8 | 9 | dependencies: 10 | args: ^2.4.2 11 | collection: ^1.17.1 12 | dotted_border: ^2.1.0 13 | file_picker: ^5.5.0 14 | fl_chart: ^0.70.2 15 | flutter: 16 | sdk: flutter 17 | flutter_hooks: ^0.18.6 18 | flutter_improved_scrolling: ^0.0.3 19 | freezed_annotation: ^2.2.0 20 | hooks_riverpod: ^2.3.6 21 | json_annotation: ^4.8.1 22 | quiver: ^3.2.1 23 | responsive_grid_list: ^1.3.2 24 | transmission_rpc: 25 | git: https://github.com/flafydev/transmission_rpc.git 26 | url_launcher: ^6.1.14 27 | window_manager: ^0.3.7 28 | xdg_desktop_portal: ^0.1.12 29 | 30 | dev_dependencies: 31 | build_runner: ^2.4.6 32 | custom_lint: ^0.4.0 33 | dependency_validator: ^3.0.0 34 | freezed: ^2.3.5 35 | json_serializable: ^6.7.1 36 | riverpod_lint: ^1.3.2 37 | very_good_analysis: ^5.0.0+1 38 | 39 | flutter: 40 | uses-material-design: true 41 | -------------------------------------------------------------------------------- /lib/models/config.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: invalid_annotation_target 2 | 3 | import 'package:flutter/foundation.dart'; 4 | import 'package:flutter/widgets.dart'; 5 | import 'package:freezed_annotation/freezed_annotation.dart'; 6 | 7 | part 'config.freezed.dart'; 8 | part 'config.g.dart'; 9 | 10 | @freezed 11 | class Config with _$Config { 12 | const factory Config({ 13 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) Color? color, 14 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) Color? backgroundColor, 15 | String? connection, 16 | bool? smoothScroll, 17 | bool? animateOnlyOnFocus, 18 | }) = _Config; 19 | 20 | factory Config.fromJson(Map json) => _$ConfigFromJson(json); 21 | } 22 | 23 | Color? _colorFromJson(String colorString) { 24 | final intColor = int.tryParse(colorString, radix: 16); 25 | if (intColor == null) { 26 | return null; 27 | } else { 28 | return Color(intColor); 29 | } 30 | } 31 | 32 | String _colorToJson(Color? color) => color?.value.toRadixString(16) ?? ''; 33 | -------------------------------------------------------------------------------- /lib/models/config.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'config.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_Config _$$_ConfigFromJson(Map json) => _$_Config( 10 | color: _colorFromJson(json['color'] as String), 11 | backgroundColor: _colorFromJson(json['backgroundColor'] as String), 12 | connection: json['connection'] as String?, 13 | smoothScroll: json['smoothScroll'] as bool?, 14 | animateOnlyOnFocus: json['animateOnlyOnFocus'] as bool?, 15 | ); 16 | 17 | Map _$$_ConfigToJson(_$_Config instance) => { 18 | 'color': _colorToJson(instance.color), 19 | 'backgroundColor': _colorToJson(instance.backgroundColor), 20 | 'connection': instance.connection, 21 | 'smoothScroll': instance.smoothScroll, 22 | 'animateOnlyOnFocus': instance.animateOnlyOnFocus, 23 | }; 24 | -------------------------------------------------------------------------------- /linux/flutter/generated_plugin_registrant.cc: -------------------------------------------------------------------------------- 1 | // 2 | // Generated file. Do not edit. 3 | // 4 | 5 | // clang-format off 6 | 7 | #include "generated_plugin_registrant.h" 8 | 9 | #include 10 | #include 11 | #include 12 | 13 | void fl_register_plugins(FlPluginRegistry* registry) { 14 | g_autoptr(FlPluginRegistrar) screen_retriever_registrar = 15 | fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); 16 | screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); 17 | g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = 18 | fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); 19 | url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); 20 | g_autoptr(FlPluginRegistrar) window_manager_registrar = 21 | fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); 22 | window_manager_plugin_register_with_registrar(window_manager_registrar); 23 | } 24 | -------------------------------------------------------------------------------- /nix/package.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | flutter327, 4 | cacert, 5 | makeDesktopItem, 6 | copyDesktopItems, 7 | }: 8 | flutter327.buildFlutterApplication rec { 9 | pname = "flarrent"; 10 | version = "0.1.0"; 11 | 12 | src = ../.; 13 | 14 | nativeBuildInputs = [copyDesktopItems]; 15 | 16 | autoPubspecLock = src + "/pubspec.lock"; 17 | gitHashes = { 18 | transmission_rpc = "sha256-0y5vjoa/Md2mpIk9Kx67yLhd9V4n4q7naAooL3mtRBw="; 19 | }; 20 | 21 | desktopItems = makeDesktopItem { 22 | name = pname; 23 | comment = meta.description; 24 | exec = "${pname} --torrent %U"; 25 | terminal = false; 26 | type = "Application"; 27 | mimeTypes = ["application/x-bittorrent" "x-scheme-handler/magnet"]; 28 | categories = ["Network" "FileTransfer" "P2P" "X-Flutter"]; 29 | keywords = ["p2p" "bittorrent" "transmission" "rpc"]; 30 | startupWMClass = "flarrent"; 31 | desktopName = "Flarrent"; 32 | genericName = "Transmission Frontend"; 33 | }; 34 | 35 | meta = with lib; { 36 | description = "Torrent frontend for Transmission"; 37 | license = licenses.gpl3Only; 38 | platforms = platforms.linux; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /lib/widgets/common/button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 3 | 4 | class InkButton extends HookConsumerWidget { 5 | const InkButton({ 6 | super.key, 7 | required this.child, 8 | this.borderRadius, 9 | this.padding, 10 | this.color, 11 | this.onPressed, 12 | }); 13 | 14 | final Widget child; 15 | final BorderRadius? borderRadius; 16 | final Color? color; 17 | final EdgeInsets? padding; 18 | final void Function()? onPressed; 19 | 20 | @override 21 | Widget build(BuildContext context, WidgetRef ref) { 22 | return InkWell( 23 | borderRadius: borderRadius, 24 | splashColor: Colors.blue.shade700.withOpacity(0.2), 25 | highlightColor: Colors.blue.shade700.withOpacity(0.2), 26 | onTap: onPressed, 27 | child: color != null || padding != null 28 | ? Container( 29 | decoration: BoxDecoration( 30 | borderRadius: borderRadius, 31 | color: color, 32 | ), 33 | padding: padding, 34 | child: child, 35 | ) 36 | : child, 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/widgets/common/smooth_scrolling.dart: -------------------------------------------------------------------------------- 1 | import 'package:flarrent/state/config.dart'; 2 | import 'package:flutter/widgets.dart'; 3 | import 'package:flutter_hooks/flutter_hooks.dart'; 4 | import 'package:flutter_improved_scrolling/flutter_improved_scrolling.dart'; 5 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 6 | 7 | class SmoothScrolling extends HookConsumerWidget { 8 | const SmoothScrolling({ 9 | super.key, 10 | required this.builder, 11 | this.multiplier = 2.5, 12 | }); 13 | 14 | final Widget Function(BuildContext context, ScrollController scrollController, ScrollPhysics? physics) builder; 15 | final double multiplier; 16 | 17 | @override 18 | Widget build(BuildContext context, WidgetRef ref) { 19 | final enable = ref.watch(configProvider.select((c) => c.value!.smoothScroll!)); 20 | final scrollController = useScrollController(); 21 | 22 | if (!enable) return builder(context, scrollController, null); 23 | 24 | return ImprovedScrolling( 25 | scrollController: scrollController, 26 | enableCustomMouseWheelScrolling: true, 27 | customMouseWheelScrollConfig: CustomMouseWheelScrollConfig( 28 | scrollDuration: const Duration(milliseconds: 300), 29 | scrollAmountMultiplier: multiplier, 30 | ), 31 | child: Builder( 32 | builder: (context) { 33 | return builder(context, scrollController, const NeverScrollableScrollPhysics()); 34 | }, 35 | ), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/models/filters.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'filters.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_Filters _$$_FiltersFromJson(Map json) => _$_Filters( 10 | query: json['query'] as String, 11 | states: (json['states'] as List) 12 | .map((e) => $enumDecode(_$TorrentStateEnumMap, e)) 13 | .toList(), 14 | sortBy: $enumDecode(_$SortByEnumMap, json['sortBy']), 15 | ascending: json['ascending'] as bool, 16 | ); 17 | 18 | Map _$$_FiltersToJson(_$_Filters instance) => 19 | { 20 | 'query': instance.query, 21 | 'states': instance.states.map((e) => _$TorrentStateEnumMap[e]!).toList(), 22 | 'sortBy': _$SortByEnumMap[instance.sortBy]!, 23 | 'ascending': instance.ascending, 24 | }; 25 | 26 | const _$TorrentStateEnumMap = { 27 | TorrentState.downloading: 'downloading', 28 | TorrentState.seeding: 'seeding', 29 | TorrentState.paused: 'paused', 30 | TorrentState.queued: 'queued', 31 | TorrentState.completed: 'completed', 32 | TorrentState.error: 'error', 33 | }; 34 | 35 | const _$SortByEnumMap = { 36 | SortBy.name: 'name', 37 | SortBy.downloadSpeed: 'downloadSpeed', 38 | SortBy.uploadSpeed: 'uploadSpeed', 39 | SortBy.size: 'size', 40 | SortBy.addedOn: 'addedOn', 41 | SortBy.completedOn: 'completedOn', 42 | }; 43 | -------------------------------------------------------------------------------- /lib/utils/multiselect_algo.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/services.dart'; 4 | 5 | List multiselectAlgo({ 6 | required List selectedIndexes, 7 | required int index, 8 | VoidCallback? selectionDefault, 9 | }) { 10 | final shiftKeys = [LogicalKeyboardKey.shiftLeft, LogicalKeyboardKey.shiftRight]; 11 | 12 | final ctrlKeys = [ 13 | LogicalKeyboardKey.control, 14 | LogicalKeyboardKey.controlLeft, 15 | LogicalKeyboardKey.controlRight, 16 | ]; 17 | 18 | final isShiftPressed = RawKeyboard.instance.keysPressed.where(shiftKeys.contains).isNotEmpty; 19 | 20 | final isCtrlPressed = RawKeyboard.instance.keysPressed.where(ctrlKeys.contains).isNotEmpty; 21 | 22 | if (isShiftPressed && selectedIndexes.isNotEmpty) { 23 | var firstIndex = selectedIndexes.first; 24 | var lastIndex = 0; 25 | 26 | for (final i in selectedIndexes) { 27 | firstIndex = min(firstIndex, i); 28 | lastIndex = max(lastIndex, i); 29 | } 30 | 31 | if (firstIndex < index) { 32 | return [for (var i = firstIndex; i <= index; i++) i]; 33 | } else { 34 | return [for (var i = index; i <= lastIndex; i++) i]; 35 | } 36 | } else if (isCtrlPressed) { 37 | if (selectedIndexes.contains(index)) { 38 | return selectedIndexes.where((i) => i != index).toList(); 39 | } 40 | return [ 41 | ...selectedIndexes, 42 | index, 43 | ]; 44 | } else if (selectionDefault != null) { 45 | selectionDefault(); 46 | return selectedIndexes; 47 | } else { 48 | if (selectedIndexes.length == 1 && selectedIndexes.first == index) { 49 | return []; 50 | } else { 51 | return [index]; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/utils/use_values_changed.dart: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart'; 2 | import 'package:flutter/foundation.dart'; 3 | import 'package:flutter/widgets.dart'; 4 | import 'package:flutter_hooks/flutter_hooks.dart'; 5 | 6 | // This code is a modified version of useValueChanged from flutter_hooks 7 | 8 | void useValuesChanged(List values, {bool firstTime = false, required VoidCallback callback}) { 9 | return use(_ValuesChangedHook(values, callback, firstTime: firstTime)); 10 | } 11 | 12 | class _ValuesChangedHook extends Hook { 13 | const _ValuesChangedHook(this.values, this.valuesChanged, {required this.firstTime}); 14 | 15 | final VoidCallback valuesChanged; 16 | final List values; 17 | final bool firstTime; 18 | 19 | @override 20 | _ValuesChangedHookState createState() => _ValuesChangedHookState(); 21 | } 22 | 23 | class _ValuesChangedHookState extends HookState { 24 | @override 25 | void initHook() { 26 | super.initHook(); 27 | if (hook.firstTime) hook.valuesChanged(); 28 | } 29 | 30 | @override 31 | void didUpdateHook(_ValuesChangedHook oldHook) { 32 | super.didUpdateHook(oldHook); 33 | if (!const IterableEquality().equals(hook.values, oldHook.values)) { 34 | hook.valuesChanged(); 35 | } 36 | } 37 | 38 | @override 39 | void build(BuildContext context) {} 40 | 41 | @override 42 | String get debugLabel => 'useValuesChanged'; 43 | 44 | @override 45 | bool get debugHasShortDescription => false; 46 | 47 | @override 48 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { 49 | super.debugFillProperties(properties); 50 | properties.add(DiagnosticsProperty('values', hook.values)); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1742578646, 24 | "narHash": "sha256-GiQ40ndXRnmmbDZvuv762vS+gew1uDpFwOfgJ8tLiEs=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "94c4dbe77c0740ebba36c173672ca15a7926c993", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled. 5 | 6 | version: 7 | revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 8 | channel: stable 9 | 10 | project_type: app 11 | 12 | # Tracks metadata for the flutter migrate command 13 | migration: 14 | platforms: 15 | - platform: root 16 | create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 17 | base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 18 | - platform: android 19 | create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 20 | base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 21 | - platform: ios 22 | create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 23 | base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 24 | - platform: linux 25 | create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 26 | base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 27 | - platform: macos 28 | create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 29 | base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 30 | - platform: web 31 | create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 32 | base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 33 | - platform: windows 34 | create_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 35 | base_revision: 84a1e904f44f9b0e9c4510138010edcc653163f8 36 | 37 | # User provided section 38 | 39 | # List of Local paths (relative to this file) that should be 40 | # ignored by the migrate tool. 41 | # 42 | # Files that are not part of the templates will be ignored by default. 43 | unmanaged_files: 44 | - 'lib/main.dart' 45 | - 'ios/Runner.xcodeproj/project.pbxproj' 46 | -------------------------------------------------------------------------------- /lib/state/config.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:flarrent/models/config.dart'; 5 | import 'package:flarrent/state/cli_args.dart'; 6 | import 'package:flutter/widgets.dart'; 7 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 8 | 9 | const defaultConfig = Config( 10 | connection: 'transmission:http://localhost:9091/transmission/rpc', 11 | color: Color.fromARGB(255, 105, 188, 255), 12 | backgroundColor: Color.fromARGB(153, 0, 0, 0), 13 | smoothScroll: false, 14 | animateOnlyOnFocus: true, 15 | ); 16 | 17 | final configLocationProvider = StateProvider( 18 | (ref) => 19 | ref.watch(cliArgsProvider.select((c) => c.configLocation)) ?? 20 | '${Platform.environment['HOME']}/.config/flarrent/config.json', 21 | ); 22 | 23 | final configProvider = StreamProvider((ref) async* { 24 | final location = ref.watch(configLocationProvider); 25 | final file = File(location); 26 | 27 | if (!file.existsSync()) { 28 | file 29 | ..createSync(recursive: true) 30 | ..writeAsStringSync( 31 | const JsonEncoder.withIndent(' ').convert(defaultConfig.toJson()), 32 | ); 33 | } 34 | 35 | Future loadConfig() async { 36 | final newConf = Config.fromJson(jsonDecode(await file.readAsString()) as Map); 37 | return newConf.copyWith( 38 | connection: newConf.connection ?? defaultConfig.connection, 39 | color: newConf.color ?? defaultConfig.color, 40 | backgroundColor: newConf.backgroundColor ?? defaultConfig.backgroundColor, 41 | smoothScroll: newConf.smoothScroll ?? defaultConfig.smoothScroll, 42 | animateOnlyOnFocus: newConf.animateOnlyOnFocus ?? defaultConfig.animateOnlyOnFocus, 43 | ); 44 | } 45 | 46 | yield await loadConfig(); 47 | await for (final event in file.parent.watch()) { 48 | if (event is FileSystemModifyEvent || event is FileSystemCreateEvent) { 49 | yield await loadConfig(); 50 | } 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /lib/utils/units.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: constant_identifier_names 2 | 3 | enum Unit { 4 | GiB, 5 | MiB, 6 | KiB, 7 | B, 8 | } 9 | 10 | Unit detectUnit(int bytes) { 11 | if (bytes >= 1000 * 1000 * 1000) { 12 | return Unit.GiB; 13 | } else if (bytes >= 1000 * 1000) { 14 | return Unit.MiB; 15 | } else if (bytes >= 1000) { 16 | return Unit.KiB; 17 | } else { 18 | return Unit.KiB; 19 | } 20 | } 21 | 22 | String fromBytesToUnit(int bytes, {Unit? unit}) { 23 | final dBytes = bytes.toDouble(); 24 | switch (unit ?? detectUnit(bytes)) { 25 | case Unit.GiB: 26 | return (dBytes / 1024 / 1024 / 1024).toStringAsFixed(dBytes / 1024 / 1024 / 1024 > 100 ? 1 : 2); 27 | case Unit.MiB: 28 | return (dBytes / 1024 / 1024).toStringAsFixed(dBytes / 1024 / 1024 > 100 ? 1 : 2); 29 | case Unit.KiB: 30 | return (dBytes / 1024).toStringAsFixed(0); 31 | case Unit.B: 32 | return dBytes.toStringAsFixed(0); 33 | } 34 | } 35 | 36 | String stringBytesWithUnits(int bytes, {Unit? unit}) { 37 | unit ??= detectUnit(bytes); 38 | 39 | return '''${fromBytesToUnit(bytes, unit: unit)} ${unit.name}'''; 40 | } 41 | 42 | String formatDuration(Duration duration) { 43 | final timeUnits = { 44 | 'd': duration.inDays, 45 | 'h': duration.inHours % 24, 46 | 'm': duration.inMinutes % 60, 47 | 's': duration.inSeconds % 60, 48 | }; 49 | 50 | var format = ''; 51 | for (final entry in timeUnits.entries) { 52 | if (format != '') { 53 | if (entry.value != 0) { 54 | format += ' ${entry.value}${entry.key}'; 55 | } 56 | break; 57 | } 58 | if (entry.value > 0) { 59 | format += '${entry.value}${entry.key}'; 60 | } 61 | } 62 | return format; 63 | } 64 | 65 | String _pad(int num) => num.toString().padLeft(2, '0'); 66 | String dateTimeToString(DateTime dateTime) { 67 | return '''${dateTime.year}-${_pad(dateTime.month)}-${_pad(dateTime.day)} ${_pad(dateTime.hour)}:${_pad(dateTime.minute)}:${_pad(dateTime.second)}'''; 68 | } 69 | -------------------------------------------------------------------------------- /lib/widgets/common/side_popup.dart: -------------------------------------------------------------------------------- 1 | import 'package:flarrent/utils/rect_custom_clipper.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | class SidePopup extends StatelessWidget { 5 | const SidePopup({ 6 | required this.color, 7 | this.smoothLength = 50.0, 8 | super.key, 9 | }); 10 | 11 | final Color color; 12 | final double smoothLength; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return ClipRect( 17 | clipper: RectCustomClipper( 18 | (size) => Rect.fromLTRB( 19 | -100, 20 | -100, 21 | size.width + 100, 22 | size.height, 23 | ), 24 | ), 25 | child: CustomPaint( 26 | painter: _PathPainter( 27 | color: color, 28 | smoothLength: smoothLength, 29 | ), 30 | child: const SizedBox.expand(), 31 | ), 32 | ); 33 | } 34 | } 35 | 36 | class _PathPainter extends CustomPainter { 37 | _PathPainter({ 38 | required this.color, 39 | required this.smoothLength, 40 | }); 41 | 42 | final Paint _paint = Paint(); 43 | final Paint _paint2 = Paint(); 44 | final Color color; 45 | final double smoothLength; 46 | 47 | @override 48 | void paint(Canvas canvas, Size size) { 49 | const stroke = 1.0; 50 | _paint 51 | ..style = PaintingStyle.stroke 52 | ..strokeWidth = stroke 53 | ..isAntiAlias = true 54 | ..color = color; 55 | _paint2 56 | ..style = PaintingStyle.fill 57 | ..color = Colors.black; 58 | 59 | final path = Path() 60 | ..moveTo(0, size.height + 1) 61 | ..cubicTo( 62 | smoothLength / 2, 63 | size.height + 1, 64 | smoothLength / 2, 65 | 0, 66 | smoothLength, 67 | 0, 68 | ) 69 | ..lineTo(size.width, stroke); 70 | canvas 71 | ..drawPath( 72 | Path.from(path)..lineTo(size.width, size.height + 1), 73 | _paint2, 74 | ) 75 | ..drawPath( 76 | path, 77 | _paint, 78 | ); 79 | } 80 | 81 | @override 82 | bool shouldRepaint(CustomPainter oldDelegate) => oldDelegate != this; 83 | } 84 | -------------------------------------------------------------------------------- /lib/utils/filter_unknown_arguments.dart: -------------------------------------------------------------------------------- 1 | import 'package:args/args.dart'; 2 | 3 | /// Copied directly from https://github.com/dart-lang/sdk/blob/96ae2c3cadea68bf904e6da83d70453786025d2a/pkg/analyzer_cli/lib/src/options.dart#L207 4 | 5 | /// Return a list of command-line arguments containing all of the given [args] 6 | /// that are defined by the given [parser]. An argument is considered to be 7 | /// defined by the parser if 8 | /// - it starts with '--' and the rest of the argument (minus any value 9 | /// introduced by '=') is the name of a known option, 10 | /// - it starts with '-' and the rest of the argument (minus any value 11 | /// introduced by '=') is the name of a known abbreviation, or 12 | /// - it starts with something other than '--' or '-'. 13 | /// 14 | /// This function allows command-line tools to implement the 15 | /// '--ignore-unrecognized-flags' option. 16 | List filterUnknownArguments(List args, ArgParser parser) { 17 | final knownOptions = {}; 18 | final knownAbbreviations = {}; 19 | parser.options.forEach((String name, Option option) { 20 | knownOptions.add(name); 21 | final abbreviation = option.abbr; 22 | if (abbreviation != null) { 23 | knownAbbreviations.add(abbreviation); 24 | } 25 | if (option.negatable ?? false) { 26 | knownOptions.add('no-$name'); 27 | } 28 | }); 29 | 30 | String optionName(int prefixLength, String argument) { 31 | final equalsOffset = argument.lastIndexOf('='); 32 | if (equalsOffset < 0) { 33 | return argument.substring(prefixLength); 34 | } 35 | return argument.substring(prefixLength, equalsOffset); 36 | } 37 | 38 | final filtered = []; 39 | for (var i = 0; i < args.length; i++) { 40 | final argument = args[i]; 41 | if (argument.startsWith('--') && argument.length > 2) { 42 | if (knownOptions.contains(optionName(2, argument))) { 43 | filtered.add(argument); 44 | } 45 | } else if (argument.startsWith('-D') && argument.indexOf('=') > 0) { 46 | filtered.add(argument); 47 | } 48 | if (argument.startsWith('-') && argument.length > 1) { 49 | if (knownAbbreviations.contains(optionName(1, argument))) { 50 | filtered.add(argument); 51 | } 52 | } else { 53 | filtered.add(argument); 54 | } 55 | } 56 | return filtered; 57 | } 58 | -------------------------------------------------------------------------------- /lib/state/torrents.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:io'; 4 | 5 | import 'package:flarrent/models/torrent.dart'; 6 | import 'package:flarrent/state/config.dart'; 7 | import 'package:flarrent/state/transmission.dart'; 8 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 9 | import 'package:transmission_rpc/transmission_rpc.dart'; 10 | 11 | final torrentsProvider = StateNotifierProvider((ref) { 12 | var connection = ref.watch(configProvider.select((c) => c.valueOrNull?.connection)) ?? ''; 13 | 14 | var idx = connection.indexOf(':'); 15 | if (idx == -1) { 16 | connection = defaultConfig.connection!; 17 | idx = connection.indexOf(':'); 18 | } 19 | 20 | final connectionType = connection.substring(0, idx); 21 | final data = connection.substring(idx + 1); 22 | 23 | switch (connectionType) { 24 | case 'transmission': 25 | return TransmissionTorrents(ref, transmission: Transmission(url: Uri.parse(data))); 26 | default: 27 | throw Exception('Unknown connection type: $connectionType'); 28 | } 29 | }); 30 | 31 | abstract class Torrents extends StateNotifier { 32 | Torrents(this.ref, TorrentsState state) : super(state); 33 | 34 | final StateNotifierProviderRef ref; 35 | 36 | Future addTorrent(String link) async { 37 | if (link.startsWith('magnet:')) { 38 | await addTorrentMagnet(link); 39 | } else if (File(link).existsSync()) { 40 | await addTorrentBase64(const Base64Encoder().convert(File(link).readAsBytesSync())); 41 | } else { 42 | await addTorrentBase64(link); 43 | } 44 | } 45 | 46 | Future pause(List ids); 47 | 48 | Future addTorrentMagnet(String magnet); 49 | 50 | Future addTorrentBase64(String base64); 51 | 52 | Future resume(List ids); 53 | 54 | Future deleteTorrent(List ids, {required bool deleteData}); 55 | 56 | Future changePriority(List ids, TorrentPriority newPriority); 57 | 58 | Future changeFilePriority(int torrentId, List files, TorrentPriority newPriority); 59 | 60 | Future setAlternativeLimits({required bool enabled}); 61 | 62 | Future setTorrentsLimit(List ids, int? downloadBytesLimit, int? uploadBytesLimit); 63 | 64 | Future pauseFiles(int torrentId, List files); 65 | 66 | Future resumeFiles(int torrentId, List files); 67 | 68 | Future setMainTorrents(List torrentIds); 69 | } 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flarrent 2 | 3 | Torrent client frontend for Transmission. 4 | This app will not run Transmission for you. It can only connect to a Transmission server that's already running. 5 | 6 | Platforms: Linux 7 | 8 | ## Showcase 9 | 10 | https://github.com/FlafyDev/flarrent/assets/44374434/c068209f-fda6-401e-860c-a94962e2b793 11 | 12 | ## Usage 13 | 14 | Run the application by [manually compiling it](#building) or by running `nix run github:flafydev/flarrent` with Nix. 15 | Additionally, you need to have a Transmission server running on localhost with port 9091. (Url can be configured) 16 | 17 | ## Building 18 | 19 | 1. Get `flutter`. (Make sure you can build to the desired platform with `flutter doctor`) 20 | 2. Clone this repository and cd into it. 21 | 3. Run `flutter build linux --release` 22 | 4. Launch the executable generated in `./build/linux/x64/release/bundle/flarrent`. 23 | 24 | Currently the only way to get a Desktop file is by building with Nix. `result/share/applications/flarrent.desktop`. 25 | 26 | ### Nix 27 | 28 | Alternatively, you can build with Nix: `nix build github:flafydev/flarrent`. 29 | 30 | ## Cli Args 31 | `--torrent ` - Add a torrent. `` can be a magnet link, file path, or the base64 of a torrent file. 32 | 33 | `--config ` - Change the location of the config file. 34 | 35 | ## Config 36 | 37 | Located by default in `~/.config/flarrent/config.json`. If the file/directories don't exist, launching the app will 38 | create them. 39 | 40 | Default config: 41 | ```json 42 | { 43 | "color": "ff69bcff", 44 | "backgroundColor": "99000000", 45 | "connection": "transmission:http://localhost:9091/transmission/rpc", 46 | "smoothScroll": false, 47 | "animateOnlyOnFocus": true 48 | } 49 | ``` 50 | 51 | 52 | ## Setting Transmission Server Options Through the Client 53 | 54 | At the moment, this client does not include the functionality to configure Transmission server options, 55 | such as altering the 'peer port,' changing the download directory, or setting global limits (though you can still toggle alternative limits). 56 | 57 | The reason for this omission is that I, the creator of this client, have configured my own Transmission server using Nix. 58 | This setup allows me to define various settings through Nix code. 59 | Therefore, incorporating the ability to configure server options through the client would not provide any advantages for me. 60 | 61 | However, I am open to pull requests (PRs). If you wish to see this feature added, please consider contributing it if you can! 😄 62 | -------------------------------------------------------------------------------- /lib/widgets/common/responsive_horizontal_grid.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math' as math; 2 | 3 | import 'package:flarrent/widgets/common/smooth_scrolling.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:quiver/iterables.dart' as quiver; 6 | 7 | class ResponsiveHorizontalGrid extends StatelessWidget { 8 | const ResponsiveHorizontalGrid({ 9 | super.key, 10 | required this.children, 11 | required this.minWidgetWidth, 12 | required this.maxWidgetWidth, 13 | required this.widgetHeight, 14 | }); 15 | 16 | final List children; 17 | final double minWidgetWidth; 18 | final double widgetHeight; 19 | final double maxWidgetWidth; 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | return LayoutBuilder( 24 | builder: (context, contraints) { 25 | final sectionedChildren = quiver 26 | .partition(children, (contraints.maxHeight / widgetHeight).floor()) 27 | .toList(); 28 | 29 | final sectionsCount = math.min( 30 | (contraints.maxWidth / minWidgetWidth).floor(), 31 | sectionedChildren.length, 32 | ); 33 | 34 | final width = math.min( 35 | contraints.maxWidth, 36 | maxWidgetWidth * sectionsCount, 37 | ); 38 | 39 | 40 | final sectionWidth = width / sectionsCount; 41 | 42 | final abandonedChildrenPre = sectionedChildren.sublist(sectionsCount); 43 | var abandonedChildren = []; 44 | 45 | if (abandonedChildrenPre.isNotEmpty) { 46 | abandonedChildren = abandonedChildrenPre 47 | .reduce((value, element) => value + element) 48 | .toList(); 49 | } 50 | 51 | return SizedBox( 52 | width: width, 53 | child: Row( 54 | children: List.generate( 55 | sectionsCount, 56 | (i) { 57 | return SizedBox( 58 | width: sectionWidth, 59 | child: SmoothScrolling( 60 | multiplier: 1, 61 | builder: (context, scrollController, physics) { 62 | return ListView( 63 | controller: scrollController, 64 | physics: physics, 65 | children: i < sectionedChildren.length 66 | ? (sectionedChildren[i] + 67 | (i == 0 ? abandonedChildren : [])) 68 | .map( 69 | (child) => SizedBox( 70 | width: sectionWidth, 71 | height: widgetHeight, 72 | child: child, 73 | ), 74 | ) 75 | .toList() 76 | : [], 77 | ); 78 | }, 79 | ), 80 | ); 81 | }, 82 | ), 83 | ), 84 | ); 85 | }, 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /linux/flutter/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # This file controls Flutter-level build steps. It should not be edited. 2 | cmake_minimum_required(VERSION 3.10) 3 | 4 | set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") 5 | 6 | # Configuration provided via flutter tool. 7 | include(${EPHEMERAL_DIR}/generated_config.cmake) 8 | 9 | # TODO: Move the rest of this into files in ephemeral. See 10 | # https://github.com/flutter/flutter/issues/57146. 11 | 12 | # Serves the same purpose as list(TRANSFORM ... PREPEND ...), 13 | # which isn't available in 3.10. 14 | function(list_prepend LIST_NAME PREFIX) 15 | set(NEW_LIST "") 16 | foreach(element ${${LIST_NAME}}) 17 | list(APPEND NEW_LIST "${PREFIX}${element}") 18 | endforeach(element) 19 | set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) 20 | endfunction() 21 | 22 | # === Flutter Library === 23 | # System-level dependencies. 24 | find_package(PkgConfig REQUIRED) 25 | pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) 26 | pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) 27 | pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) 28 | 29 | set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") 30 | 31 | # Published to parent scope for install step. 32 | set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) 33 | set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) 34 | set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) 35 | set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) 36 | 37 | list(APPEND FLUTTER_LIBRARY_HEADERS 38 | "fl_basic_message_channel.h" 39 | "fl_binary_codec.h" 40 | "fl_binary_messenger.h" 41 | "fl_dart_project.h" 42 | "fl_engine.h" 43 | "fl_json_message_codec.h" 44 | "fl_json_method_codec.h" 45 | "fl_message_codec.h" 46 | "fl_method_call.h" 47 | "fl_method_channel.h" 48 | "fl_method_codec.h" 49 | "fl_method_response.h" 50 | "fl_plugin_registrar.h" 51 | "fl_plugin_registry.h" 52 | "fl_standard_message_codec.h" 53 | "fl_standard_method_codec.h" 54 | "fl_string_codec.h" 55 | "fl_value.h" 56 | "fl_view.h" 57 | "flutter_linux.h" 58 | ) 59 | list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") 60 | add_library(flutter INTERFACE) 61 | target_include_directories(flutter INTERFACE 62 | "${EPHEMERAL_DIR}" 63 | ) 64 | target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") 65 | target_link_libraries(flutter INTERFACE 66 | PkgConfig::GTK 67 | PkgConfig::GLIB 68 | PkgConfig::GIO 69 | ) 70 | add_dependencies(flutter flutter_assemble) 71 | 72 | # === Flutter tool backend === 73 | # _phony_ is a non-existent file to force this command to run every time, 74 | # since currently there's no way to get a full input/output list from the 75 | # flutter tool. 76 | add_custom_command( 77 | OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} 78 | ${CMAKE_CURRENT_BINARY_DIR}/_phony_ 79 | COMMAND ${CMAKE_COMMAND} -E env 80 | ${FLUTTER_TOOL_ENVIRONMENT} 81 | "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" 82 | ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} 83 | VERBATIM 84 | ) 85 | add_custom_target(flutter_assemble DEPENDS 86 | "${FLUTTER_LIBRARY}" 87 | ${FLUTTER_LIBRARY_HEADERS} 88 | ) 89 | -------------------------------------------------------------------------------- /lib/widgets/torrent/torrent_overview/torrent_overview.dart: -------------------------------------------------------------------------------- 1 | import 'package:flarrent/widgets/torrent/torrent_overview/overview_files.dart'; 2 | import 'package:flarrent/widgets/torrent/torrent_overview/overview_info.dart'; 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_hooks/flutter_hooks.dart'; 5 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 6 | 7 | class TorrentOverview extends HookConsumerWidget { 8 | const TorrentOverview({super.key, required this.id}); 9 | 10 | final int id; 11 | 12 | @override 13 | Widget build(BuildContext context, WidgetRef ref) { 14 | final theme = Theme.of(context); 15 | final pageController = usePageController(); 16 | final currentPage = useValueNotifier(0); 17 | final icons = [ 18 | Icons.info, 19 | Icons.folder, 20 | ]; 21 | 22 | useEffect( 23 | () { 24 | void callback() { 25 | currentPage.value = pageController.page?.round() ?? 0; 26 | } 27 | 28 | pageController.addListener(callback); 29 | return () => pageController.removeListener(callback); 30 | }, 31 | [pageController], 32 | ); 33 | 34 | // final aa = ref.watch(transmissionMainTorrentsProvider.select((s) => s.isReloading )); 35 | 36 | // The idea is not update the overview with a new torrent id if data is not loaded yet. 37 | // final exists = ref.watch(torrentsProvider.select((s) => s.torrents.any((t) => t.id == id))); 38 | // if (!exists) { 39 | // if (prevId == id || prevId == null) { 40 | // return const SizedBox(); 41 | // } else { 42 | // shownId = prevId; 43 | // } 44 | // } 45 | 46 | return Row( 47 | children: [ 48 | Padding( 49 | padding: const EdgeInsets.all(4), 50 | child: Column( 51 | children: icons 52 | .asMap() 53 | .entries 54 | .map( 55 | (e) => SizedBox( 56 | width: 40, 57 | height: 40, 58 | child: TextButton( 59 | child: ValueListenableBuilder( 60 | valueListenable: currentPage, 61 | builder: (context, val, child) { 62 | return Icon( 63 | e.value, 64 | color: val == e.key ? theme.colorScheme.onSecondary : Colors.white, 65 | ); 66 | }, 67 | ), 68 | onPressed: () { 69 | pageController.animateToPage( 70 | e.key, 71 | duration: const Duration(milliseconds: 200), 72 | curve: Curves.easeOutExpo, 73 | ); 74 | }, 75 | ), 76 | ), 77 | ) 78 | .toList(), 79 | ), 80 | ), 81 | Expanded( 82 | child: PageView( 83 | controller: pageController, 84 | physics: const NeverScrollableScrollPhysics(), 85 | scrollDirection: Axis.vertical, 86 | children: [ 87 | OverviewInfo(id: id), 88 | OverviewFiles(id: id), 89 | ], 90 | ), 91 | ), 92 | ], 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /lib/models/torrent.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/foundation.dart'; 2 | import 'package:freezed_annotation/freezed_annotation.dart'; 3 | 4 | part 'torrent.freezed.dart'; 5 | part 'torrent.g.dart'; 6 | 7 | enum TorrentState { 8 | downloading, 9 | seeding, 10 | paused, 11 | queued, 12 | completed, 13 | error, 14 | } 15 | 16 | enum TorrentPriority { 17 | low, 18 | normal, 19 | high, 20 | } 21 | 22 | @freezed 23 | class TorrentQuickData with _$TorrentQuickData { 24 | const factory TorrentQuickData({ 25 | required int id, 26 | required String name, 27 | required int downloadedBytes, 28 | required int sizeToDownloadBytes, 29 | required int sizeBytes, 30 | required Duration estimatedTimeLeft, 31 | required int downloadBytesPerSecond, 32 | required bool downloadLimited, 33 | required int uploadBytesPerSecond, 34 | required bool uploadLimited, 35 | required TorrentState state, 36 | required TorrentPriority priority, 37 | DateTime? addedOn, 38 | DateTime? completedOn, 39 | }) = _TorrentQuickData; 40 | 41 | factory TorrentQuickData.fromJson(Map json) => _$TorrentQuickDataFromJson(json); 42 | } 43 | 44 | @freezed 45 | class TorrentData with _$TorrentData { 46 | const factory TorrentData({ 47 | required int id, 48 | required String name, 49 | required int downloadedBytes, 50 | required int sizeToDownloadBytes, 51 | required int sizeBytes, 52 | required Duration estimatedTimeLeft, 53 | required int downloadBytesPerSecond, 54 | required int uploadBytesPerSecond, 55 | required TorrentState state, 56 | required bool downloadLimited, 57 | required bool uploadLimited, 58 | required int downloadLimitBytesPerSecond, 59 | required int uploadLimitBytesPerSecond, 60 | required TorrentPriority priority, 61 | DateTime? addedOn, 62 | DateTime? completedOn, 63 | DateTime? lastActivity, 64 | String? location, 65 | String? magnet, 66 | String? torrentFileLocation, 67 | required double ratio, 68 | int? uploadedEverBytes, 69 | int? downloadedEverBytes, 70 | Duration? timeDownloading, 71 | Duration? timeSeeding, 72 | required List files, 73 | required List peers, 74 | required List trackers, 75 | }) = _TorrentData; 76 | 77 | factory TorrentData.fromJson(Map json) => _$TorrentDataFromJson(json); 78 | } 79 | 80 | @freezed 81 | class TorrentFileData with _$TorrentFileData { 82 | const factory TorrentFileData({ 83 | required String name, 84 | required int downloadedBytes, 85 | required int sizeBytes, 86 | required TorrentPriority priority, 87 | required TorrentState state, 88 | }) = _TorrentFileData; 89 | 90 | factory TorrentFileData.fromJson(Map json) => _$TorrentFileDataFromJson(json); 91 | } 92 | 93 | @freezed 94 | class TorrentsState with _$TorrentsState { 95 | const factory TorrentsState({ 96 | required ClientState client, 97 | required Map> downloadSpeeds, 98 | required Map> uploadSpeeds, 99 | required List quickTorrents, 100 | required List torrents, 101 | }) = _TorrentsState; 102 | 103 | factory TorrentsState.fromJson(Map json) => _$TorrentsStateFromJson(json); 104 | } 105 | 106 | @freezed 107 | class ClientState with _$ClientState { 108 | const factory ClientState({ 109 | required int downloadSpeedBytesPerSecond, 110 | required int uploadSpeedBytesPerSecond, 111 | required int? downloadLimitBytesPerSecond, 112 | required int? uploadLimitBytesPerSecond, 113 | required bool alternativeSpeedLimitsEnabled, 114 | required String connectionString, 115 | int? freeSpaceBytes, 116 | }) = _ClientState; 117 | 118 | factory ClientState.fromJson(Map json) => _$ClientStateFromJson(json); 119 | } 120 | -------------------------------------------------------------------------------- /lib/widgets/smooth_graph/get_y_from_x.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: implementation_imports 2 | 3 | import 'dart:math'; 4 | import 'dart:ui'; 5 | 6 | import 'package:fl_chart/fl_chart.dart'; 7 | import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart'; 8 | import 'package:fl_chart/src/chart/line_chart/line_chart_painter.dart'; 9 | 10 | // Edited version of `generateNormalBarPath` from package:fl_chart/src/chart/line_chart/line_chart_painter.dart. 11 | (double, double) getYFromX( 12 | double x, 13 | double initialT, 14 | Size viewSize, 15 | LineChartBarData barData, 16 | List barSpots, 17 | PaintHolder holder, 18 | ) { 19 | final instance = LineChartPainter(); 20 | 21 | final size = barSpots.length; 22 | 23 | var temp = Offset.zero; 24 | 25 | for (var i = 1; i < size; i++) { 26 | /// CurrentSpot 27 | final current = Offset( 28 | instance.getPixelX(barSpots[i].x, viewSize, holder), 29 | instance.getPixelY(barSpots[i].y, viewSize, holder), 30 | ); 31 | 32 | /// previous spot 33 | final previous = Offset( 34 | instance.getPixelX(barSpots[i - 1].x, viewSize, holder), 35 | instance.getPixelY(barSpots[i - 1].y, viewSize, holder), 36 | ); 37 | 38 | /// next point 39 | final next = Offset( 40 | instance.getPixelX( 41 | barSpots[i + 1 < size ? i + 1 : i].x, 42 | viewSize, 43 | holder, 44 | ), 45 | instance.getPixelY( 46 | barSpots[i + 1 < size ? i + 1 : i].y, 47 | viewSize, 48 | holder, 49 | ), 50 | ); 51 | 52 | final controlPoint1 = previous + temp; 53 | 54 | /// if the isCurved is false, we set 0 for smoothness, 55 | /// it means we should not have any smoothness then we face with 56 | /// the sharped corners line 57 | final smoothness = barData.isCurved ? barData.curveSmoothness : 0.0; 58 | temp = ((next - previous) / 2) * smoothness; 59 | 60 | if (barData.preventCurveOverShooting) { 61 | if ((next - current).dy <= barData.preventCurveOvershootingThreshold || 62 | (current - previous).dy <= barData.preventCurveOvershootingThreshold) { 63 | temp = Offset(temp.dx, 0); 64 | } 65 | 66 | if ((next - current).dx <= barData.preventCurveOvershootingThreshold || 67 | (current - previous).dx <= barData.preventCurveOvershootingThreshold) { 68 | temp = Offset(0, temp.dy); 69 | } 70 | } 71 | 72 | final controlPoint2 = current - temp; 73 | 74 | if (i == size - 2) { 75 | final t = findT( 76 | x, 77 | previous, 78 | controlPoint1, 79 | controlPoint2, 80 | current, 81 | initialT: initialT, 82 | epsilon: 1e-1, 83 | ); 84 | final y = cubicBezierCurveY(t, previous, controlPoint1, controlPoint2, current); 85 | 86 | return (y, t); 87 | } 88 | } 89 | 90 | return (0, 0); 91 | } 92 | 93 | double cubicBezierCurveY(double t, Offset p0, Offset p1, Offset p2, Offset p3) { 94 | final y = pow(1 - t, 3) * p0.dy + 3 * pow(1 - t, 2) * t * p1.dy + 3 * (1 - t) * pow(t, 2) * p2.dy + pow(t, 3) * p3.dy; 95 | return y; 96 | } 97 | 98 | double findT( 99 | double x, 100 | Offset p0, 101 | Offset p1, 102 | Offset p2, 103 | Offset p3, { 104 | double epsilon = 1e-6, 105 | double maxIterations = 100, 106 | double initialT = 0, 107 | }) { 108 | var t0 = 0.0; 109 | var t1 = 1.0; 110 | 111 | // Perform bisection to find t 112 | for (var i = 0; i < maxIterations; i++) { 113 | // Calculate the midpoint of the interval 114 | final t = (t0 + t1) / 2.0; 115 | 116 | // Calculate the X value at the current t 117 | final xT = (1 - t) * (1 - t) * (1 - t) * p0.dx + 118 | 3 * (1 - t) * (1 - t) * t * p1.dx + 119 | 3 * (1 - t) * t * t * p2.dx + 120 | t * t * t * p3.dx; 121 | 122 | // Check if the current X value is close enough to the desired X value 123 | if ((xT - x).abs() < epsilon) { 124 | return t; 125 | } 126 | 127 | // Update the interval based on the X value 128 | if (xT < x) { 129 | t0 = t; 130 | } else { 131 | t1 = t; 132 | } 133 | } 134 | 135 | // If the desired precision is not achieved within the maximum iterations, 136 | // return the best approximation found 137 | return t1; 138 | } 139 | -------------------------------------------------------------------------------- /linux/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # Project-level configuration. 2 | cmake_minimum_required(VERSION 3.10) 3 | project(runner LANGUAGES CXX) 4 | 5 | # The name of the executable created for the application. Change this to change 6 | # the on-disk name of your application. 7 | set(BINARY_NAME "flarrent") 8 | # The unique GTK application identifier for this application. See: 9 | # https://wiki.gnome.org/HowDoI/ChooseApplicationID 10 | set(APPLICATION_ID "com.example.flarrent") 11 | 12 | # Explicitly opt in to modern CMake behaviors to avoid warnings with recent 13 | # versions of CMake. 14 | cmake_policy(SET CMP0063 NEW) 15 | 16 | # Load bundled libraries from the lib/ directory relative to the binary. 17 | set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") 18 | 19 | # Root filesystem for cross-building. 20 | if(FLUTTER_TARGET_PLATFORM_SYSROOT) 21 | set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) 22 | set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) 23 | set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) 24 | set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) 25 | set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) 26 | set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) 27 | endif() 28 | 29 | # Define build configuration options. 30 | if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) 31 | set(CMAKE_BUILD_TYPE "Debug" CACHE 32 | STRING "Flutter build mode" FORCE) 33 | set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS 34 | "Debug" "Profile" "Release") 35 | endif() 36 | 37 | # Compilation settings that should be applied to most targets. 38 | # 39 | # Be cautious about adding new options here, as plugins use this function by 40 | # default. In most cases, you should add new options to specific targets instead 41 | # of modifying this function. 42 | function(APPLY_STANDARD_SETTINGS TARGET) 43 | target_compile_features(${TARGET} PUBLIC cxx_std_14) 44 | target_compile_options(${TARGET} PRIVATE -Wall -Werror) 45 | target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") 46 | target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") 47 | endfunction() 48 | 49 | # Flutter library and tool build rules. 50 | set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") 51 | add_subdirectory(${FLUTTER_MANAGED_DIR}) 52 | 53 | # System-level dependencies. 54 | find_package(PkgConfig REQUIRED) 55 | pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) 56 | 57 | add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") 58 | 59 | # Define the application target. To change its name, change BINARY_NAME above, 60 | # not the value here, or `flutter run` will no longer work. 61 | # 62 | # Any new source files that you add to the application should be added here. 63 | add_executable(${BINARY_NAME} 64 | "main.cc" 65 | "my_application.cc" 66 | "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" 67 | ) 68 | 69 | # Apply the standard set of build settings. This can be removed for applications 70 | # that need different build settings. 71 | apply_standard_settings(${BINARY_NAME}) 72 | 73 | # Add dependency libraries. Add any application-specific dependencies here. 74 | target_link_libraries(${BINARY_NAME} PRIVATE flutter) 75 | target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) 76 | 77 | # Run the Flutter tool portions of the build. This must not be removed. 78 | add_dependencies(${BINARY_NAME} flutter_assemble) 79 | 80 | # Only the install-generated bundle's copy of the executable will launch 81 | # correctly, since the resources must in the right relative locations. To avoid 82 | # people trying to run the unbundled copy, put it in a subdirectory instead of 83 | # the default top-level location. 84 | set_target_properties(${BINARY_NAME} 85 | PROPERTIES 86 | RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" 87 | ) 88 | 89 | 90 | # Generated plugin build rules, which manage building the plugins and adding 91 | # them to the application. 92 | include(flutter/generated_plugins.cmake) 93 | 94 | 95 | # === Installation === 96 | # By default, "installing" just makes a relocatable bundle in the build 97 | # directory. 98 | set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") 99 | if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) 100 | set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) 101 | endif() 102 | 103 | # Start with a clean build bundle directory every time. 104 | install(CODE " 105 | file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") 106 | " COMPONENT Runtime) 107 | 108 | set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") 109 | set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") 110 | 111 | install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" 112 | COMPONENT Runtime) 113 | 114 | install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" 115 | COMPONENT Runtime) 116 | 117 | install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 118 | COMPONENT Runtime) 119 | 120 | foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) 121 | install(FILES "${bundled_library}" 122 | DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 123 | COMPONENT Runtime) 124 | endforeach(bundled_library) 125 | 126 | # Fully re-copy the assets directory on each build to avoid having stale files 127 | # from a previous install. 128 | set(FLUTTER_ASSET_DIR_NAME "flutter_assets") 129 | install(CODE " 130 | file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") 131 | " COMPONENT Runtime) 132 | install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" 133 | DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) 134 | 135 | # Install the AOT library on non-Debug builds only. 136 | if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") 137 | install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" 138 | COMPONENT Runtime) 139 | endif() 140 | -------------------------------------------------------------------------------- /lib/models/cli_args.freezed.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // GENERATED CODE - DO NOT MODIFY BY HAND 3 | // ignore_for_file: type=lint 4 | // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark 5 | 6 | part of 'cli_args.dart'; 7 | 8 | // ************************************************************************** 9 | // FreezedGenerator 10 | // ************************************************************************** 11 | 12 | T _$identity(T value) => value; 13 | 14 | final _privateConstructorUsedError = UnsupportedError( 15 | 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); 16 | 17 | CliArgs _$CliArgsFromJson(Map json) { 18 | return _CliArgs.fromJson(json); 19 | } 20 | 21 | /// @nodoc 22 | mixin _$CliArgs { 23 | String? get configLocation => throw _privateConstructorUsedError; 24 | List? get torrentsLinks => throw _privateConstructorUsedError; 25 | 26 | Map toJson() => throw _privateConstructorUsedError; 27 | @JsonKey(ignore: true) 28 | $CliArgsCopyWith get copyWith => throw _privateConstructorUsedError; 29 | } 30 | 31 | /// @nodoc 32 | abstract class $CliArgsCopyWith<$Res> { 33 | factory $CliArgsCopyWith(CliArgs value, $Res Function(CliArgs) then) = 34 | _$CliArgsCopyWithImpl<$Res, CliArgs>; 35 | @useResult 36 | $Res call({String? configLocation, List? torrentsLinks}); 37 | } 38 | 39 | /// @nodoc 40 | class _$CliArgsCopyWithImpl<$Res, $Val extends CliArgs> 41 | implements $CliArgsCopyWith<$Res> { 42 | _$CliArgsCopyWithImpl(this._value, this._then); 43 | 44 | // ignore: unused_field 45 | final $Val _value; 46 | // ignore: unused_field 47 | final $Res Function($Val) _then; 48 | 49 | @pragma('vm:prefer-inline') 50 | @override 51 | $Res call({ 52 | Object? configLocation = freezed, 53 | Object? torrentsLinks = freezed, 54 | }) { 55 | return _then(_value.copyWith( 56 | configLocation: freezed == configLocation 57 | ? _value.configLocation 58 | : configLocation // ignore: cast_nullable_to_non_nullable 59 | as String?, 60 | torrentsLinks: freezed == torrentsLinks 61 | ? _value.torrentsLinks 62 | : torrentsLinks // ignore: cast_nullable_to_non_nullable 63 | as List?, 64 | ) as $Val); 65 | } 66 | } 67 | 68 | /// @nodoc 69 | abstract class _$$_CliArgsCopyWith<$Res> implements $CliArgsCopyWith<$Res> { 70 | factory _$$_CliArgsCopyWith( 71 | _$_CliArgs value, $Res Function(_$_CliArgs) then) = 72 | __$$_CliArgsCopyWithImpl<$Res>; 73 | @override 74 | @useResult 75 | $Res call({String? configLocation, List? torrentsLinks}); 76 | } 77 | 78 | /// @nodoc 79 | class __$$_CliArgsCopyWithImpl<$Res> 80 | extends _$CliArgsCopyWithImpl<$Res, _$_CliArgs> 81 | implements _$$_CliArgsCopyWith<$Res> { 82 | __$$_CliArgsCopyWithImpl(_$_CliArgs _value, $Res Function(_$_CliArgs) _then) 83 | : super(_value, _then); 84 | 85 | @pragma('vm:prefer-inline') 86 | @override 87 | $Res call({ 88 | Object? configLocation = freezed, 89 | Object? torrentsLinks = freezed, 90 | }) { 91 | return _then(_$_CliArgs( 92 | configLocation: freezed == configLocation 93 | ? _value.configLocation 94 | : configLocation // ignore: cast_nullable_to_non_nullable 95 | as String?, 96 | torrentsLinks: freezed == torrentsLinks 97 | ? _value._torrentsLinks 98 | : torrentsLinks // ignore: cast_nullable_to_non_nullable 99 | as List?, 100 | )); 101 | } 102 | } 103 | 104 | /// @nodoc 105 | @JsonSerializable() 106 | class _$_CliArgs with DiagnosticableTreeMixin implements _CliArgs { 107 | const _$_CliArgs({this.configLocation, final List? torrentsLinks}) 108 | : _torrentsLinks = torrentsLinks; 109 | 110 | factory _$_CliArgs.fromJson(Map json) => 111 | _$$_CliArgsFromJson(json); 112 | 113 | @override 114 | final String? configLocation; 115 | final List? _torrentsLinks; 116 | @override 117 | List? get torrentsLinks { 118 | final value = _torrentsLinks; 119 | if (value == null) return null; 120 | if (_torrentsLinks is EqualUnmodifiableListView) return _torrentsLinks; 121 | // ignore: implicit_dynamic_type 122 | return EqualUnmodifiableListView(value); 123 | } 124 | 125 | @override 126 | String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { 127 | return 'CliArgs(configLocation: $configLocation, torrentsLinks: $torrentsLinks)'; 128 | } 129 | 130 | @override 131 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { 132 | super.debugFillProperties(properties); 133 | properties 134 | ..add(DiagnosticsProperty('type', 'CliArgs')) 135 | ..add(DiagnosticsProperty('configLocation', configLocation)) 136 | ..add(DiagnosticsProperty('torrentsLinks', torrentsLinks)); 137 | } 138 | 139 | @override 140 | bool operator ==(dynamic other) { 141 | return identical(this, other) || 142 | (other.runtimeType == runtimeType && 143 | other is _$_CliArgs && 144 | (identical(other.configLocation, configLocation) || 145 | other.configLocation == configLocation) && 146 | const DeepCollectionEquality() 147 | .equals(other._torrentsLinks, _torrentsLinks)); 148 | } 149 | 150 | @JsonKey(ignore: true) 151 | @override 152 | int get hashCode => Object.hash(runtimeType, configLocation, 153 | const DeepCollectionEquality().hash(_torrentsLinks)); 154 | 155 | @JsonKey(ignore: true) 156 | @override 157 | @pragma('vm:prefer-inline') 158 | _$$_CliArgsCopyWith<_$_CliArgs> get copyWith => 159 | __$$_CliArgsCopyWithImpl<_$_CliArgs>(this, _$identity); 160 | 161 | @override 162 | Map toJson() { 163 | return _$$_CliArgsToJson( 164 | this, 165 | ); 166 | } 167 | } 168 | 169 | abstract class _CliArgs implements CliArgs { 170 | const factory _CliArgs( 171 | {final String? configLocation, 172 | final List? torrentsLinks}) = _$_CliArgs; 173 | 174 | factory _CliArgs.fromJson(Map json) = _$_CliArgs.fromJson; 175 | 176 | @override 177 | String? get configLocation; 178 | @override 179 | List? get torrentsLinks; 180 | @override 181 | @JsonKey(ignore: true) 182 | _$$_CliArgsCopyWith<_$_CliArgs> get copyWith => 183 | throw _privateConstructorUsedError; 184 | } 185 | -------------------------------------------------------------------------------- /linux/my_application.cc: -------------------------------------------------------------------------------- 1 | #include "my_application.h" 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include "flutter/generated_plugin_registrant.h" 10 | #include 11 | 12 | struct _MyApplication { 13 | GtkApplication parent_instance; 14 | char** dart_entrypoint_arguments; 15 | }; 16 | 17 | G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) 18 | 19 | static GtkWindow *window = nullptr; 20 | 21 | // static void respond(FlMethodCall *method_call, FlMethodResponse *response) { 22 | // g_autoptr(GError) error = nullptr; 23 | // if (!fl_method_call_respond(method_call, response, &error)) { 24 | // g_warning("Failed to send method call response: %s", error->message); 25 | // } 26 | // } 27 | 28 | static void method_call_cb(FlMethodChannel *channel, FlMethodCall *method_call, gpointer user_data) { 29 | // const gchar *method = fl_method_call_get_name(method_call); 30 | // const FlValue *args = fl_method_call_get_args(method_call); 31 | 32 | // if (strcmp(method, "focusable") == 0) { 33 | // FlValue *focusable = fl_value_lookup_string(args, "focusable"); 34 | // 35 | // if (focusable != nullptr && fl_value_get_type(focusable) == FL_VALUE_TYPE_BOOL) { 36 | // gtk_layer_set_keyboard_mode(window, fl_value_get_bool(focusable) ? GTK_LAYER_SHELL_KEYBOARD_MODE_ON_DEMAND : GTK_LAYER_SHELL_KEYBOARD_MODE_NONE); 37 | // g_autoptr(FlMethodResponse) response = FL_METHOD_RESPONSE(rl_method_success_response_new(fl_value_new_null())); 38 | // respond(method_call, response); 39 | // return; 40 | // } 41 | // } 42 | 43 | g_autoptr(FlMethodResponse) response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); 44 | g_autoptr(GError) error = nullptr; 45 | fl_method_call_respond(method_call, response, &error); 46 | } 47 | 48 | // Implements GApplication::activate. 49 | static void my_application_activate(GApplication* application) { 50 | MyApplication* self = MY_APPLICATION(application); 51 | 52 | GList *list = gtk_application_get_windows(GTK_APPLICATION(application)); 53 | GtkWindow* existing_window = list ? GTK_WINDOW(list->data) : NULL; 54 | 55 | if (existing_window) { 56 | gtk_window_present(existing_window); 57 | return; 58 | } 59 | 60 | window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); 61 | 62 | gboolean use_header_bar = FALSE; 63 | if (use_header_bar) { 64 | GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); 65 | gtk_widget_show(GTK_WIDGET(header_bar)); 66 | gtk_header_bar_set_title(header_bar, "Flarrent"); 67 | gtk_header_bar_set_show_close_button(header_bar, TRUE); 68 | gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); 69 | } else { 70 | gtk_window_set_title(window, "Flarrent"); 71 | } 72 | 73 | GdkScreen* gdkScreen; 74 | GdkVisual* visual; 75 | gtk_widget_set_app_paintable(GTK_WIDGET(window), TRUE); 76 | gdkScreen = gdk_screen_get_default(); 77 | visual = gdk_screen_get_rgba_visual(gdkScreen); 78 | if (visual != NULL && gdk_screen_is_composited(gdkScreen)) { 79 | gtk_widget_set_visual(GTK_WIDGET(window), visual); 80 | } 81 | 82 | gtk_window_set_default_size(window, 1280, 720); 83 | gtk_widget_show(GTK_WIDGET(window)); 84 | 85 | g_autoptr(FlDartProject) project = fl_dart_project_new(); 86 | fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); 87 | 88 | FlView* view = fl_view_new(project); 89 | gtk_widget_show(GTK_WIDGET(view)); 90 | gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); 91 | 92 | fl_register_plugins(FL_PLUGIN_REGISTRY(view)); 93 | 94 | FlEngine *engine = fl_view_get_engine(view); 95 | g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); 96 | g_autoptr(FlBinaryMessenger) messenger = fl_engine_get_binary_messenger(engine); 97 | g_autoptr(FlMethodChannel) channel = fl_method_channel_new(messenger, "general", FL_METHOD_CODEC(codec)); 98 | fl_method_channel_set_method_call_handler(channel, method_call_cb, g_object_ref(view), g_object_unref); 99 | 100 | gtk_widget_grab_focus(GTK_WIDGET(view)); 101 | } 102 | 103 | static void send_torrents(char** arguments) { 104 | std::vector torrentArgs; 105 | 106 | for (int i = 0; arguments[i] != NULL; i++) { 107 | const char* argument = arguments[i]; 108 | 109 | if (strcmp(argument, "--torrent") == 0 && arguments[i + 1] != NULL) { 110 | torrentArgs.push_back(arguments[i + 1]); 111 | } 112 | } 113 | 114 | // Create a socket 115 | int sockfd = socket(AF_UNIX, SOCK_STREAM, 0); 116 | if (sockfd == -1) { 117 | perror("socket"); 118 | return; 119 | } 120 | 121 | // Set up the server address structure 122 | struct sockaddr_un server_address; 123 | server_address.sun_family = AF_UNIX; 124 | strcpy(server_address.sun_path, "/tmp/flarrent.sock"); 125 | 126 | // Connect to the server 127 | if (connect(sockfd, (struct sockaddr*)&server_address, sizeof(server_address)) == -1) { 128 | perror("connect"); 129 | close(sockfd); 130 | return; 131 | } 132 | 133 | for (const std::string& argument : torrentArgs) { 134 | // Data to send 135 | const std::string message = "torrent " + argument; 136 | 137 | // Send the data 138 | send(sockfd, message.c_str(), message.length(), 0); 139 | send(sockfd, ";", 1, 0); 140 | } 141 | 142 | // Close the socket 143 | close(sockfd); 144 | } 145 | 146 | // Implements GApplication::local_command_line. 147 | static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { 148 | MyApplication* self = MY_APPLICATION(application); 149 | 150 | // Strip out the first argument as it is the binary name. 151 | self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); 152 | 153 | g_autoptr(GError) error = nullptr; 154 | if (!g_application_register(application, nullptr, &error)) { 155 | g_warning("Failed to register: %s", error->message); 156 | *exit_status = 1; 157 | return TRUE; 158 | } 159 | 160 | if (g_application_get_is_remote(application)) { 161 | send_torrents(self->dart_entrypoint_arguments); 162 | } 163 | 164 | g_application_activate(application); 165 | *exit_status = 0; 166 | 167 | return TRUE; 168 | } 169 | 170 | // Implements GObject::dispose. 171 | static void my_application_dispose(GObject* object) { 172 | MyApplication* self = MY_APPLICATION(object); 173 | g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); 174 | G_OBJECT_CLASS(my_application_parent_class)->dispose(object); 175 | } 176 | 177 | static void my_application_class_init(MyApplicationClass* klass) { 178 | G_APPLICATION_CLASS(klass)->activate = my_application_activate; 179 | G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; 180 | G_OBJECT_CLASS(klass)->dispose = my_application_dispose; 181 | } 182 | 183 | static void my_application_init(MyApplication* self) {} 184 | 185 | MyApplication* my_application_new() { 186 | return MY_APPLICATION(g_object_new(my_application_get_type(), 187 | "application-id", APPLICATION_ID, 188 | nullptr)); 189 | } 190 | -------------------------------------------------------------------------------- /lib/models/filters.freezed.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // GENERATED CODE - DO NOT MODIFY BY HAND 3 | // ignore_for_file: type=lint 4 | // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark 5 | 6 | part of 'filters.dart'; 7 | 8 | // ************************************************************************** 9 | // FreezedGenerator 10 | // ************************************************************************** 11 | 12 | T _$identity(T value) => value; 13 | 14 | final _privateConstructorUsedError = UnsupportedError( 15 | 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); 16 | 17 | Filters _$FiltersFromJson(Map json) { 18 | return _Filters.fromJson(json); 19 | } 20 | 21 | /// @nodoc 22 | mixin _$Filters { 23 | String get query => throw _privateConstructorUsedError; 24 | List get states => throw _privateConstructorUsedError; 25 | SortBy get sortBy => throw _privateConstructorUsedError; 26 | bool get ascending => throw _privateConstructorUsedError; 27 | 28 | Map toJson() => throw _privateConstructorUsedError; 29 | @JsonKey(ignore: true) 30 | $FiltersCopyWith get copyWith => throw _privateConstructorUsedError; 31 | } 32 | 33 | /// @nodoc 34 | abstract class $FiltersCopyWith<$Res> { 35 | factory $FiltersCopyWith(Filters value, $Res Function(Filters) then) = 36 | _$FiltersCopyWithImpl<$Res, Filters>; 37 | @useResult 38 | $Res call( 39 | {String query, List states, SortBy sortBy, bool ascending}); 40 | } 41 | 42 | /// @nodoc 43 | class _$FiltersCopyWithImpl<$Res, $Val extends Filters> 44 | implements $FiltersCopyWith<$Res> { 45 | _$FiltersCopyWithImpl(this._value, this._then); 46 | 47 | // ignore: unused_field 48 | final $Val _value; 49 | // ignore: unused_field 50 | final $Res Function($Val) _then; 51 | 52 | @pragma('vm:prefer-inline') 53 | @override 54 | $Res call({ 55 | Object? query = null, 56 | Object? states = null, 57 | Object? sortBy = null, 58 | Object? ascending = null, 59 | }) { 60 | return _then(_value.copyWith( 61 | query: null == query 62 | ? _value.query 63 | : query // ignore: cast_nullable_to_non_nullable 64 | as String, 65 | states: null == states 66 | ? _value.states 67 | : states // ignore: cast_nullable_to_non_nullable 68 | as List, 69 | sortBy: null == sortBy 70 | ? _value.sortBy 71 | : sortBy // ignore: cast_nullable_to_non_nullable 72 | as SortBy, 73 | ascending: null == ascending 74 | ? _value.ascending 75 | : ascending // ignore: cast_nullable_to_non_nullable 76 | as bool, 77 | ) as $Val); 78 | } 79 | } 80 | 81 | /// @nodoc 82 | abstract class _$$_FiltersCopyWith<$Res> implements $FiltersCopyWith<$Res> { 83 | factory _$$_FiltersCopyWith( 84 | _$_Filters value, $Res Function(_$_Filters) then) = 85 | __$$_FiltersCopyWithImpl<$Res>; 86 | @override 87 | @useResult 88 | $Res call( 89 | {String query, List states, SortBy sortBy, bool ascending}); 90 | } 91 | 92 | /// @nodoc 93 | class __$$_FiltersCopyWithImpl<$Res> 94 | extends _$FiltersCopyWithImpl<$Res, _$_Filters> 95 | implements _$$_FiltersCopyWith<$Res> { 96 | __$$_FiltersCopyWithImpl(_$_Filters _value, $Res Function(_$_Filters) _then) 97 | : super(_value, _then); 98 | 99 | @pragma('vm:prefer-inline') 100 | @override 101 | $Res call({ 102 | Object? query = null, 103 | Object? states = null, 104 | Object? sortBy = null, 105 | Object? ascending = null, 106 | }) { 107 | return _then(_$_Filters( 108 | query: null == query 109 | ? _value.query 110 | : query // ignore: cast_nullable_to_non_nullable 111 | as String, 112 | states: null == states 113 | ? _value._states 114 | : states // ignore: cast_nullable_to_non_nullable 115 | as List, 116 | sortBy: null == sortBy 117 | ? _value.sortBy 118 | : sortBy // ignore: cast_nullable_to_non_nullable 119 | as SortBy, 120 | ascending: null == ascending 121 | ? _value.ascending 122 | : ascending // ignore: cast_nullable_to_non_nullable 123 | as bool, 124 | )); 125 | } 126 | } 127 | 128 | /// @nodoc 129 | @JsonSerializable() 130 | class _$_Filters with DiagnosticableTreeMixin implements _Filters { 131 | const _$_Filters( 132 | {required this.query, 133 | required final List states, 134 | required this.sortBy, 135 | required this.ascending}) 136 | : _states = states; 137 | 138 | factory _$_Filters.fromJson(Map json) => 139 | _$$_FiltersFromJson(json); 140 | 141 | @override 142 | final String query; 143 | final List _states; 144 | @override 145 | List get states { 146 | if (_states is EqualUnmodifiableListView) return _states; 147 | // ignore: implicit_dynamic_type 148 | return EqualUnmodifiableListView(_states); 149 | } 150 | 151 | @override 152 | final SortBy sortBy; 153 | @override 154 | final bool ascending; 155 | 156 | @override 157 | String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { 158 | return 'Filters(query: $query, states: $states, sortBy: $sortBy, ascending: $ascending)'; 159 | } 160 | 161 | @override 162 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { 163 | super.debugFillProperties(properties); 164 | properties 165 | ..add(DiagnosticsProperty('type', 'Filters')) 166 | ..add(DiagnosticsProperty('query', query)) 167 | ..add(DiagnosticsProperty('states', states)) 168 | ..add(DiagnosticsProperty('sortBy', sortBy)) 169 | ..add(DiagnosticsProperty('ascending', ascending)); 170 | } 171 | 172 | @override 173 | bool operator ==(dynamic other) { 174 | return identical(this, other) || 175 | (other.runtimeType == runtimeType && 176 | other is _$_Filters && 177 | (identical(other.query, query) || other.query == query) && 178 | const DeepCollectionEquality().equals(other._states, _states) && 179 | (identical(other.sortBy, sortBy) || other.sortBy == sortBy) && 180 | (identical(other.ascending, ascending) || 181 | other.ascending == ascending)); 182 | } 183 | 184 | @JsonKey(ignore: true) 185 | @override 186 | int get hashCode => Object.hash(runtimeType, query, 187 | const DeepCollectionEquality().hash(_states), sortBy, ascending); 188 | 189 | @JsonKey(ignore: true) 190 | @override 191 | @pragma('vm:prefer-inline') 192 | _$$_FiltersCopyWith<_$_Filters> get copyWith => 193 | __$$_FiltersCopyWithImpl<_$_Filters>(this, _$identity); 194 | 195 | @override 196 | Map toJson() { 197 | return _$$_FiltersToJson( 198 | this, 199 | ); 200 | } 201 | } 202 | 203 | abstract class _Filters implements Filters { 204 | const factory _Filters( 205 | {required final String query, 206 | required final List states, 207 | required final SortBy sortBy, 208 | required final bool ascending}) = _$_Filters; 209 | 210 | factory _Filters.fromJson(Map json) = _$_Filters.fromJson; 211 | 212 | @override 213 | String get query; 214 | @override 215 | List get states; 216 | @override 217 | SortBy get sortBy; 218 | @override 219 | bool get ascending; 220 | @override 221 | @JsonKey(ignore: true) 222 | _$$_FiltersCopyWith<_$_Filters> get copyWith => 223 | throw _privateConstructorUsedError; 224 | } 225 | -------------------------------------------------------------------------------- /lib/widgets/torrent/torrent_overview/overview_info.dart: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart'; 2 | import 'package:flarrent/state/torrents.dart'; 3 | import 'package:flarrent/utils/capitalize_string.dart'; 4 | import 'package:flarrent/utils/units.dart'; 5 | import 'package:flarrent/widgets/common/responsive_horizontal_grid.dart'; 6 | import 'package:flarrent/widgets/smooth_graph/smooth_graph.dart'; 7 | import 'package:flutter/material.dart'; 8 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 9 | 10 | class OverviewInfo extends HookConsumerWidget { 11 | const OverviewInfo({ 12 | super.key, 13 | required this.id, 14 | }); 15 | 16 | final int id; 17 | 18 | @override 19 | Widget build(BuildContext context, WidgetRef ref) { 20 | final theme = Theme.of(context); 21 | 22 | return LayoutBuilder( 23 | builder: (context, contraints) { 24 | final maxWidth = contraints.maxWidth; 25 | final width = (maxWidth / 3).clamp(300, 500).toDouble(); 26 | return Row( 27 | children: [ 28 | Container( 29 | constraints: const BoxConstraints(maxHeight: 400), 30 | width: width, 31 | child: Column( 32 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 33 | crossAxisAlignment: CrossAxisAlignment.start, 34 | children: List.generate( 35 | 2, 36 | (i) { 37 | return _TorrentGraph( 38 | id: id, 39 | theme: theme, 40 | isDownload: i == 0, 41 | ); 42 | }, 43 | ), 44 | ), 45 | ), 46 | Expanded( 47 | child: Consumer( 48 | builder: (context, ref, child) { 49 | final data = ref.watch( 50 | torrentsProvider.select( 51 | (v) => v.torrents.firstWhereOrNull( 52 | (element) => element.id == id, 53 | ), 54 | ), 55 | ); 56 | if (data == null) return Container(); 57 | 58 | return Center( 59 | child: ResponsiveHorizontalGrid( 60 | maxWidgetWidth: 500, 61 | minWidgetWidth: 250, 62 | widgetHeight: 30, 63 | children: [ 64 | _TorrentInfoTile('State', capitalizeString(data.state.name)), 65 | _TorrentInfoTile('Size', stringBytesWithUnits(data.sizeBytes)), 66 | if (data.sizeBytes != data.sizeToDownloadBytes) 67 | _TorrentInfoTile('Download size', stringBytesWithUnits(data.sizeToDownloadBytes)), 68 | _TorrentInfoTile('Downloaded', stringBytesWithUnits(data.downloadedBytes)), 69 | if (data.addedOn != null) _TorrentInfoTile('Added on', dateTimeToString(data.addedOn!)), 70 | if (data.completedOn != null) 71 | _TorrentInfoTile('Completed on', dateTimeToString(data.completedOn!)), 72 | if (data.lastActivity != null) 73 | _TorrentInfoTile('Last activity', dateTimeToString(data.lastActivity!)), 74 | if (data.timeDownloading != null) 75 | _TorrentInfoTile('Time downloading', formatDuration(data.timeDownloading!)), 76 | if (data.timeSeeding != null) 77 | _TorrentInfoTile('Time seeding', formatDuration(data.timeSeeding!)), 78 | _TorrentInfoTile('Ratio', data.ratio.toStringAsFixed(2)), 79 | 80 | if (data.downloadedEverBytes != null) 81 | _TorrentInfoTile('Downloaded ever', stringBytesWithUnits(data.downloadedEverBytes!)), 82 | if (data.uploadedEverBytes != null) 83 | _TorrentInfoTile('Uploaded ever', stringBytesWithUnits(data.uploadedEverBytes!)), 84 | if (data.location != null) _TorrentInfoTile('location', data.location!), 85 | ], 86 | ), 87 | ); 88 | }, 89 | ), 90 | ), 91 | ], 92 | ); 93 | }, 94 | ); 95 | } 96 | } 97 | 98 | class _TorrentGraph extends HookConsumerWidget { 99 | const _TorrentGraph({ 100 | required this.id, 101 | required this.theme, 102 | required this.isDownload, 103 | // ignore: unused_element 104 | super.key, 105 | }); 106 | 107 | final int id; 108 | final ThemeData theme; 109 | final bool isDownload; 110 | 111 | @override 112 | Widget build(BuildContext context, WidgetRef ref) { 113 | List read() => ref.read( 114 | torrentsProvider.select( 115 | (v) => isDownload ? v.downloadSpeeds[id]! : v.uploadSpeeds[id]!, 116 | ), 117 | ); 118 | 119 | return Expanded( 120 | child: Container( 121 | padding: const EdgeInsets.all(4), 122 | child: Row( 123 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 124 | children: [ 125 | Expanded( 126 | child: SmoothChart( 127 | tint: isDownload ? Colors.lightBlue : Colors.purple, 128 | key: ValueKey(id), 129 | getInitialPointsY: (i) { 130 | return read()[i].toDouble() / 1024 / 1024; 131 | }, 132 | getNextPointY: () { 133 | return read().last.toDouble() / 1024 / 1024; 134 | }, 135 | ), 136 | ), 137 | const SizedBox(width: 12), 138 | SizedBox( 139 | width: 120, 140 | child: Consumer( 141 | builder: (context, ref, child) { 142 | final bytesPerSecond = ref.watch( 143 | torrentsProvider.select( 144 | (v) { 145 | final speeds = isDownload ? v.downloadSpeeds[id] : v.uploadSpeeds[id]; 146 | if (speeds == null) return 0; 147 | return speeds[speeds.length - 3]; 148 | }, 149 | ), 150 | ); 151 | 152 | final unit = detectUnit(bytesPerSecond); 153 | 154 | return RichText( 155 | text: TextSpan( 156 | children: [ 157 | TextSpan( 158 | text: fromBytesToUnit( 159 | bytesPerSecond, 160 | unit: unit, 161 | ), 162 | style: const TextStyle(fontSize: 24), 163 | ), 164 | TextSpan( 165 | text: ' ${unit.name}/s', 166 | style: TextStyle( 167 | fontSize: 12, 168 | color: theme.colorScheme.onPrimary, 169 | ), 170 | ), 171 | WidgetSpan( 172 | child: Icon( 173 | isDownload ? Icons.arrow_downward : Icons.arrow_upward, 174 | size: 16, 175 | ), 176 | ), 177 | ], 178 | ), 179 | ); 180 | }, 181 | ), 182 | ), 183 | ], 184 | ), 185 | ), 186 | ); 187 | } 188 | } 189 | 190 | class _TorrentInfoTile extends StatelessWidget { 191 | const _TorrentInfoTile( 192 | this.title, 193 | this.value, { 194 | // ignore: unused_element 195 | super.key, 196 | }); 197 | 198 | final String title; 199 | final String value; 200 | 201 | @override 202 | Widget build(BuildContext context) { 203 | return Padding( 204 | padding: const EdgeInsets.symmetric(horizontal: 16), 205 | child: Row( 206 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 207 | children: [ 208 | Text(title), 209 | const SizedBox( 210 | width: 10, 211 | ), 212 | Flexible( 213 | child: Text( 214 | value, 215 | overflow: TextOverflow.ellipsis, 216 | maxLines: 1, 217 | ), 218 | ), 219 | ], 220 | ), 221 | ); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | 4 | import 'package:args/args.dart'; 5 | import 'package:collection/collection.dart'; 6 | import 'package:flarrent/models/cli_args.dart'; 7 | import 'package:flarrent/state/cli_args.dart'; 8 | import 'package:flarrent/state/config.dart'; 9 | import 'package:flarrent/state/torrents.dart'; 10 | import 'package:flarrent/utils/filter_unknown_arguments.dart'; 11 | import 'package:flarrent/utils/rect_custom_clipper.dart'; 12 | import 'package:flarrent/widgets/main_view.dart'; 13 | import 'package:flarrent/widgets/side_view.dart'; 14 | import 'package:flutter/material.dart'; 15 | import 'package:flutter_hooks/flutter_hooks.dart'; 16 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 17 | 18 | void main(List args) async { 19 | final parser = ArgParser() 20 | ..addOption('config') 21 | ..addMultiOption('torrent'); 22 | final results = parser.parse( 23 | const IterableEquality().equals(args, ['--torrent']) 24 | ? [] 25 | : filterUnknownArguments(args, parser), 26 | ); 27 | 28 | final container = ProviderContainer( 29 | overrides: [ 30 | cliArgsProvider.overrideWithValue( 31 | CliArgs( 32 | configLocation: results['config'] as String?, 33 | torrentsLinks: results['torrent'] as List?, 34 | ), 35 | ), 36 | ], 37 | ); 38 | 39 | for (final link 40 | in container.read(cliArgsProvider).torrentsLinks ?? []) { 41 | unawaited(container.read(torrentsProvider.notifier).addTorrent(link)); 42 | } 43 | 44 | final sockFile = File('/tmp/flarrent.sock'); 45 | if (sockFile.existsSync()) { 46 | sockFile.deleteSync(); 47 | } 48 | 49 | final server = await ServerSocket.bind( 50 | InternetAddress( 51 | sockFile.path, 52 | type: InternetAddressType.unix, 53 | ), 54 | 0, 55 | ); 56 | 57 | server.listen((socket) { 58 | socket.listen((data) { 59 | final messages = String.fromCharCodes(data).trim().split(';') 60 | ..removeWhere((element) => element.isEmpty); 61 | for (final message in messages) { 62 | if (message.startsWith('torrent ')) { 63 | container 64 | .read(torrentsProvider.notifier) 65 | .addTorrent(message.substring('torrent '.length)); 66 | } 67 | } 68 | }); 69 | }); 70 | 71 | runApp( 72 | UncontrolledProviderScope( 73 | container: container, 74 | child: HookBuilder( 75 | builder: (context) { 76 | useOnAppLifecycleStateChange((prev, next) { 77 | if (next == AppLifecycleState.detached) { 78 | server.close(); 79 | } 80 | }); 81 | return const MyApp(); 82 | }, 83 | ), 84 | ), 85 | ); 86 | } 87 | 88 | class MyApp extends HookConsumerWidget { 89 | const MyApp({super.key}); 90 | 91 | @override 92 | Widget build(BuildContext context, WidgetRef ref) { 93 | final config = ref.watch(configProvider).valueOrNull; 94 | if (config == null) { 95 | return const SizedBox(); 96 | } 97 | 98 | final color = config.color!; 99 | 100 | return MaterialApp( 101 | theme: ThemeData( 102 | scaffoldBackgroundColor: config.backgroundColor, 103 | colorScheme: ColorScheme.dark( 104 | primary: Colors.white, 105 | secondary: Colors.transparent, 106 | background: Colors.transparent, 107 | // surface: Colors.transparent, 108 | onPrimary: Colors.white, 109 | shadow: Colors.black.withOpacity(0.2), 110 | onSecondary: color, 111 | surfaceVariant: HSLColor.fromColor(color) 112 | .withSaturation(0.2) 113 | .withLightness(0.2) 114 | .toColor(), 115 | surface: HSLColor.fromColor(color) 116 | .withSaturation(1) 117 | .withLightness(0.2) 118 | .toColor(), 119 | ), 120 | // splashColor: color, 121 | dropdownMenuTheme: DropdownMenuThemeData( 122 | menuStyle: MenuStyle( 123 | backgroundColor: MaterialStateProperty.all(Colors.black), 124 | ), 125 | ), 126 | inputDecorationTheme: InputDecorationTheme( 127 | focusColor: Colors.white, 128 | focusedBorder: OutlineInputBorder( 129 | borderRadius: BorderRadius.circular(10), 130 | borderSide: BorderSide(color: color), 131 | ), 132 | hoverColor: Colors.white, 133 | enabledBorder: OutlineInputBorder( 134 | borderRadius: BorderRadius.circular(10), 135 | borderSide: BorderSide(color: color.withAlpha(055)), 136 | ), 137 | ), 138 | shadowColor: Colors.black.withOpacity(0.2), 139 | ), 140 | home: const Scaffold( 141 | body: SizedBox.expand( 142 | child: AppEntry(), 143 | ), 144 | ), 145 | ); 146 | } 147 | } 148 | 149 | class AppEntry extends HookConsumerWidget { 150 | const AppEntry({ 151 | super.key, 152 | }); 153 | 154 | @override 155 | Widget build(BuildContext context, WidgetRef ref) { 156 | final prevOnTop = useRef(true); 157 | final sideOpenAC = useAnimationController( 158 | duration: const Duration(milliseconds: 300), 159 | ); 160 | final openSideTimer = useRef(null); 161 | final theme = Theme.of(context); 162 | 163 | return LayoutBuilder( 164 | builder: (context, constraints) { 165 | final sideOnTop = constraints.maxWidth < 1000; 166 | if (prevOnTop.value != sideOnTop) { 167 | sideOpenAC.animateTo( 168 | sideOnTop ? 0 : 1, 169 | curve: Curves.easeOutExpo, 170 | ); 171 | prevOnTop.value = sideOnTop; 172 | } 173 | 174 | return AnimatedBuilder( 175 | animation: sideOpenAC, 176 | builder: (context, child) { 177 | final sideOpen = sideOpenAC.value; 178 | final sideWidth = 300 * sideOpenAC.value; 179 | return Stack( 180 | children: [ 181 | MouseRegion( 182 | onExit: (e) { 183 | if (sideOnTop && sideOpenAC.value != 0) { 184 | sideOpenAC.animateTo( 185 | 0, 186 | curve: Curves.easeOutExpo, 187 | ); 188 | } 189 | }, 190 | child: ClipRect( 191 | child: Align( 192 | alignment: AlignmentDirectional.centerEnd, 193 | widthFactor: sideOpen, 194 | child: SizedBox( 195 | width: 300, 196 | child: Stack( 197 | children: [ 198 | const SideView(), 199 | Align( 200 | alignment: Alignment.centerRight, 201 | child: VerticalDivider( 202 | width: 1, 203 | color: theme.colorScheme.onSecondary, 204 | ), 205 | ), 206 | ], 207 | ), 208 | ), 209 | ), 210 | ), 211 | ), 212 | Positioned( 213 | left: sideOnTop ? 0 : sideWidth, 214 | top: 0, 215 | width: sideOnTop 216 | ? constraints.maxWidth 217 | : constraints.maxWidth - sideWidth, 218 | height: constraints.maxHeight, 219 | child: ClipRect( 220 | clipper: RectCustomClipper( 221 | (size) => Rect.fromLTRB( 222 | sideOnTop ? sideWidth : 0, 223 | 0, 224 | size.width, 225 | size.height, 226 | ), 227 | ), 228 | child: MainView( 229 | menuPlace: sideOnTop, 230 | onMenuPlaceExit: () { 231 | openSideTimer.value?.cancel(); 232 | openSideTimer.value = null; 233 | }, 234 | onMenuPlaceEnter: () { 235 | openSideTimer.value?.cancel(); 236 | openSideTimer.value = 237 | Timer(const Duration(milliseconds: 100), () { 238 | sideOpenAC.animateTo( 239 | 1, 240 | curve: Curves.easeOutExpo, 241 | ); 242 | openSideTimer.value?.cancel(); 243 | openSideTimer.value = null; 244 | }); 245 | }, 246 | ), 247 | ), 248 | ), 249 | ], 250 | ); 251 | }, 252 | ); 253 | }, 254 | ); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /lib/models/torrent.g.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | part of 'torrent.dart'; 4 | 5 | // ************************************************************************** 6 | // JsonSerializableGenerator 7 | // ************************************************************************** 8 | 9 | _$_TorrentQuickData _$$_TorrentQuickDataFromJson(Map json) => 10 | _$_TorrentQuickData( 11 | id: json['id'] as int, 12 | name: json['name'] as String, 13 | downloadedBytes: json['downloadedBytes'] as int, 14 | sizeToDownloadBytes: json['sizeToDownloadBytes'] as int, 15 | sizeBytes: json['sizeBytes'] as int, 16 | estimatedTimeLeft: 17 | Duration(microseconds: json['estimatedTimeLeft'] as int), 18 | downloadBytesPerSecond: json['downloadBytesPerSecond'] as int, 19 | downloadLimited: json['downloadLimited'] as bool, 20 | uploadBytesPerSecond: json['uploadBytesPerSecond'] as int, 21 | uploadLimited: json['uploadLimited'] as bool, 22 | state: $enumDecode(_$TorrentStateEnumMap, json['state']), 23 | priority: $enumDecode(_$TorrentPriorityEnumMap, json['priority']), 24 | addedOn: json['addedOn'] == null 25 | ? null 26 | : DateTime.parse(json['addedOn'] as String), 27 | completedOn: json['completedOn'] == null 28 | ? null 29 | : DateTime.parse(json['completedOn'] as String), 30 | ); 31 | 32 | Map _$$_TorrentQuickDataToJson(_$_TorrentQuickData instance) => 33 | { 34 | 'id': instance.id, 35 | 'name': instance.name, 36 | 'downloadedBytes': instance.downloadedBytes, 37 | 'sizeToDownloadBytes': instance.sizeToDownloadBytes, 38 | 'sizeBytes': instance.sizeBytes, 39 | 'estimatedTimeLeft': instance.estimatedTimeLeft.inMicroseconds, 40 | 'downloadBytesPerSecond': instance.downloadBytesPerSecond, 41 | 'downloadLimited': instance.downloadLimited, 42 | 'uploadBytesPerSecond': instance.uploadBytesPerSecond, 43 | 'uploadLimited': instance.uploadLimited, 44 | 'state': _$TorrentStateEnumMap[instance.state]!, 45 | 'priority': _$TorrentPriorityEnumMap[instance.priority]!, 46 | 'addedOn': instance.addedOn?.toIso8601String(), 47 | 'completedOn': instance.completedOn?.toIso8601String(), 48 | }; 49 | 50 | const _$TorrentStateEnumMap = { 51 | TorrentState.downloading: 'downloading', 52 | TorrentState.seeding: 'seeding', 53 | TorrentState.paused: 'paused', 54 | TorrentState.queued: 'queued', 55 | TorrentState.completed: 'completed', 56 | TorrentState.error: 'error', 57 | }; 58 | 59 | const _$TorrentPriorityEnumMap = { 60 | TorrentPriority.low: 'low', 61 | TorrentPriority.normal: 'normal', 62 | TorrentPriority.high: 'high', 63 | }; 64 | 65 | _$_TorrentData _$$_TorrentDataFromJson(Map json) => 66 | _$_TorrentData( 67 | id: json['id'] as int, 68 | name: json['name'] as String, 69 | downloadedBytes: json['downloadedBytes'] as int, 70 | sizeToDownloadBytes: json['sizeToDownloadBytes'] as int, 71 | sizeBytes: json['sizeBytes'] as int, 72 | estimatedTimeLeft: 73 | Duration(microseconds: json['estimatedTimeLeft'] as int), 74 | downloadBytesPerSecond: json['downloadBytesPerSecond'] as int, 75 | uploadBytesPerSecond: json['uploadBytesPerSecond'] as int, 76 | state: $enumDecode(_$TorrentStateEnumMap, json['state']), 77 | downloadLimited: json['downloadLimited'] as bool, 78 | uploadLimited: json['uploadLimited'] as bool, 79 | downloadLimitBytesPerSecond: json['downloadLimitBytesPerSecond'] as int, 80 | uploadLimitBytesPerSecond: json['uploadLimitBytesPerSecond'] as int, 81 | priority: $enumDecode(_$TorrentPriorityEnumMap, json['priority']), 82 | addedOn: json['addedOn'] == null 83 | ? null 84 | : DateTime.parse(json['addedOn'] as String), 85 | completedOn: json['completedOn'] == null 86 | ? null 87 | : DateTime.parse(json['completedOn'] as String), 88 | lastActivity: json['lastActivity'] == null 89 | ? null 90 | : DateTime.parse(json['lastActivity'] as String), 91 | location: json['location'] as String?, 92 | magnet: json['magnet'] as String?, 93 | torrentFileLocation: json['torrentFileLocation'] as String?, 94 | ratio: (json['ratio'] as num).toDouble(), 95 | uploadedEverBytes: json['uploadedEverBytes'] as int?, 96 | downloadedEverBytes: json['downloadedEverBytes'] as int?, 97 | timeDownloading: json['timeDownloading'] == null 98 | ? null 99 | : Duration(microseconds: json['timeDownloading'] as int), 100 | timeSeeding: json['timeSeeding'] == null 101 | ? null 102 | : Duration(microseconds: json['timeSeeding'] as int), 103 | files: (json['files'] as List) 104 | .map((e) => TorrentFileData.fromJson(e as Map)) 105 | .toList(), 106 | peers: (json['peers'] as List).map((e) => e as String).toList(), 107 | trackers: 108 | (json['trackers'] as List).map((e) => e as String).toList(), 109 | ); 110 | 111 | Map _$$_TorrentDataToJson(_$_TorrentData instance) => 112 | { 113 | 'id': instance.id, 114 | 'name': instance.name, 115 | 'downloadedBytes': instance.downloadedBytes, 116 | 'sizeToDownloadBytes': instance.sizeToDownloadBytes, 117 | 'sizeBytes': instance.sizeBytes, 118 | 'estimatedTimeLeft': instance.estimatedTimeLeft.inMicroseconds, 119 | 'downloadBytesPerSecond': instance.downloadBytesPerSecond, 120 | 'uploadBytesPerSecond': instance.uploadBytesPerSecond, 121 | 'state': _$TorrentStateEnumMap[instance.state]!, 122 | 'downloadLimited': instance.downloadLimited, 123 | 'uploadLimited': instance.uploadLimited, 124 | 'downloadLimitBytesPerSecond': instance.downloadLimitBytesPerSecond, 125 | 'uploadLimitBytesPerSecond': instance.uploadLimitBytesPerSecond, 126 | 'priority': _$TorrentPriorityEnumMap[instance.priority]!, 127 | 'addedOn': instance.addedOn?.toIso8601String(), 128 | 'completedOn': instance.completedOn?.toIso8601String(), 129 | 'lastActivity': instance.lastActivity?.toIso8601String(), 130 | 'location': instance.location, 131 | 'magnet': instance.magnet, 132 | 'torrentFileLocation': instance.torrentFileLocation, 133 | 'ratio': instance.ratio, 134 | 'uploadedEverBytes': instance.uploadedEverBytes, 135 | 'downloadedEverBytes': instance.downloadedEverBytes, 136 | 'timeDownloading': instance.timeDownloading?.inMicroseconds, 137 | 'timeSeeding': instance.timeSeeding?.inMicroseconds, 138 | 'files': instance.files, 139 | 'peers': instance.peers, 140 | 'trackers': instance.trackers, 141 | }; 142 | 143 | _$_TorrentFileData _$$_TorrentFileDataFromJson(Map json) => 144 | _$_TorrentFileData( 145 | name: json['name'] as String, 146 | downloadedBytes: json['downloadedBytes'] as int, 147 | sizeBytes: json['sizeBytes'] as int, 148 | priority: $enumDecode(_$TorrentPriorityEnumMap, json['priority']), 149 | state: $enumDecode(_$TorrentStateEnumMap, json['state']), 150 | ); 151 | 152 | Map _$$_TorrentFileDataToJson(_$_TorrentFileData instance) => 153 | { 154 | 'name': instance.name, 155 | 'downloadedBytes': instance.downloadedBytes, 156 | 'sizeBytes': instance.sizeBytes, 157 | 'priority': _$TorrentPriorityEnumMap[instance.priority]!, 158 | 'state': _$TorrentStateEnumMap[instance.state]!, 159 | }; 160 | 161 | _$_TorrentsState _$$_TorrentsStateFromJson(Map json) => 162 | _$_TorrentsState( 163 | client: ClientState.fromJson(json['client'] as Map), 164 | downloadSpeeds: (json['downloadSpeeds'] as Map).map( 165 | (k, e) => MapEntry( 166 | int.parse(k), (e as List).map((e) => e as int).toList()), 167 | ), 168 | uploadSpeeds: (json['uploadSpeeds'] as Map).map( 169 | (k, e) => MapEntry( 170 | int.parse(k), (e as List).map((e) => e as int).toList()), 171 | ), 172 | quickTorrents: (json['quickTorrents'] as List) 173 | .map((e) => TorrentQuickData.fromJson(e as Map)) 174 | .toList(), 175 | torrents: (json['torrents'] as List) 176 | .map((e) => TorrentData.fromJson(e as Map)) 177 | .toList(), 178 | ); 179 | 180 | Map _$$_TorrentsStateToJson(_$_TorrentsState instance) => 181 | { 182 | 'client': instance.client, 183 | 'downloadSpeeds': 184 | instance.downloadSpeeds.map((k, e) => MapEntry(k.toString(), e)), 185 | 'uploadSpeeds': 186 | instance.uploadSpeeds.map((k, e) => MapEntry(k.toString(), e)), 187 | 'quickTorrents': instance.quickTorrents, 188 | 'torrents': instance.torrents, 189 | }; 190 | 191 | _$_ClientState _$$_ClientStateFromJson(Map json) => 192 | _$_ClientState( 193 | downloadSpeedBytesPerSecond: json['downloadSpeedBytesPerSecond'] as int, 194 | uploadSpeedBytesPerSecond: json['uploadSpeedBytesPerSecond'] as int, 195 | downloadLimitBytesPerSecond: json['downloadLimitBytesPerSecond'] as int?, 196 | uploadLimitBytesPerSecond: json['uploadLimitBytesPerSecond'] as int?, 197 | alternativeSpeedLimitsEnabled: 198 | json['alternativeSpeedLimitsEnabled'] as bool, 199 | connectionString: json['connectionString'] as String, 200 | freeSpaceBytes: json['freeSpaceBytes'] as int?, 201 | ); 202 | 203 | Map _$$_ClientStateToJson(_$_ClientState instance) => 204 | { 205 | 'downloadSpeedBytesPerSecond': instance.downloadSpeedBytesPerSecond, 206 | 'uploadSpeedBytesPerSecond': instance.uploadSpeedBytesPerSecond, 207 | 'downloadLimitBytesPerSecond': instance.downloadLimitBytesPerSecond, 208 | 'uploadLimitBytesPerSecond': instance.uploadLimitBytesPerSecond, 209 | 'alternativeSpeedLimitsEnabled': instance.alternativeSpeedLimitsEnabled, 210 | 'connectionString': instance.connectionString, 211 | 'freeSpaceBytes': instance.freeSpaceBytes, 212 | }; 213 | -------------------------------------------------------------------------------- /lib/models/config.freezed.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // GENERATED CODE - DO NOT MODIFY BY HAND 3 | // ignore_for_file: type=lint 4 | // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark 5 | 6 | part of 'config.dart'; 7 | 8 | // ************************************************************************** 9 | // FreezedGenerator 10 | // ************************************************************************** 11 | 12 | T _$identity(T value) => value; 13 | 14 | final _privateConstructorUsedError = UnsupportedError( 15 | 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); 16 | 17 | Config _$ConfigFromJson(Map json) { 18 | return _Config.fromJson(json); 19 | } 20 | 21 | /// @nodoc 22 | mixin _$Config { 23 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 24 | Color? get color => throw _privateConstructorUsedError; 25 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 26 | Color? get backgroundColor => throw _privateConstructorUsedError; 27 | String? get connection => throw _privateConstructorUsedError; 28 | bool? get smoothScroll => throw _privateConstructorUsedError; 29 | bool? get animateOnlyOnFocus => throw _privateConstructorUsedError; 30 | 31 | Map toJson() => throw _privateConstructorUsedError; 32 | @JsonKey(ignore: true) 33 | $ConfigCopyWith get copyWith => throw _privateConstructorUsedError; 34 | } 35 | 36 | /// @nodoc 37 | abstract class $ConfigCopyWith<$Res> { 38 | factory $ConfigCopyWith(Config value, $Res Function(Config) then) = 39 | _$ConfigCopyWithImpl<$Res, Config>; 40 | @useResult 41 | $Res call( 42 | {@JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 43 | Color? color, 44 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 45 | Color? backgroundColor, 46 | String? connection, 47 | bool? smoothScroll, 48 | bool? animateOnlyOnFocus}); 49 | } 50 | 51 | /// @nodoc 52 | class _$ConfigCopyWithImpl<$Res, $Val extends Config> 53 | implements $ConfigCopyWith<$Res> { 54 | _$ConfigCopyWithImpl(this._value, this._then); 55 | 56 | // ignore: unused_field 57 | final $Val _value; 58 | // ignore: unused_field 59 | final $Res Function($Val) _then; 60 | 61 | @pragma('vm:prefer-inline') 62 | @override 63 | $Res call({ 64 | Object? color = freezed, 65 | Object? backgroundColor = freezed, 66 | Object? connection = freezed, 67 | Object? smoothScroll = freezed, 68 | Object? animateOnlyOnFocus = freezed, 69 | }) { 70 | return _then(_value.copyWith( 71 | color: freezed == color 72 | ? _value.color 73 | : color // ignore: cast_nullable_to_non_nullable 74 | as Color?, 75 | backgroundColor: freezed == backgroundColor 76 | ? _value.backgroundColor 77 | : backgroundColor // ignore: cast_nullable_to_non_nullable 78 | as Color?, 79 | connection: freezed == connection 80 | ? _value.connection 81 | : connection // ignore: cast_nullable_to_non_nullable 82 | as String?, 83 | smoothScroll: freezed == smoothScroll 84 | ? _value.smoothScroll 85 | : smoothScroll // ignore: cast_nullable_to_non_nullable 86 | as bool?, 87 | animateOnlyOnFocus: freezed == animateOnlyOnFocus 88 | ? _value.animateOnlyOnFocus 89 | : animateOnlyOnFocus // ignore: cast_nullable_to_non_nullable 90 | as bool?, 91 | ) as $Val); 92 | } 93 | } 94 | 95 | /// @nodoc 96 | abstract class _$$_ConfigCopyWith<$Res> implements $ConfigCopyWith<$Res> { 97 | factory _$$_ConfigCopyWith(_$_Config value, $Res Function(_$_Config) then) = 98 | __$$_ConfigCopyWithImpl<$Res>; 99 | @override 100 | @useResult 101 | $Res call( 102 | {@JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 103 | Color? color, 104 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 105 | Color? backgroundColor, 106 | String? connection, 107 | bool? smoothScroll, 108 | bool? animateOnlyOnFocus}); 109 | } 110 | 111 | /// @nodoc 112 | class __$$_ConfigCopyWithImpl<$Res> 113 | extends _$ConfigCopyWithImpl<$Res, _$_Config> 114 | implements _$$_ConfigCopyWith<$Res> { 115 | __$$_ConfigCopyWithImpl(_$_Config _value, $Res Function(_$_Config) _then) 116 | : super(_value, _then); 117 | 118 | @pragma('vm:prefer-inline') 119 | @override 120 | $Res call({ 121 | Object? color = freezed, 122 | Object? backgroundColor = freezed, 123 | Object? connection = freezed, 124 | Object? smoothScroll = freezed, 125 | Object? animateOnlyOnFocus = freezed, 126 | }) { 127 | return _then(_$_Config( 128 | color: freezed == color 129 | ? _value.color 130 | : color // ignore: cast_nullable_to_non_nullable 131 | as Color?, 132 | backgroundColor: freezed == backgroundColor 133 | ? _value.backgroundColor 134 | : backgroundColor // ignore: cast_nullable_to_non_nullable 135 | as Color?, 136 | connection: freezed == connection 137 | ? _value.connection 138 | : connection // ignore: cast_nullable_to_non_nullable 139 | as String?, 140 | smoothScroll: freezed == smoothScroll 141 | ? _value.smoothScroll 142 | : smoothScroll // ignore: cast_nullable_to_non_nullable 143 | as bool?, 144 | animateOnlyOnFocus: freezed == animateOnlyOnFocus 145 | ? _value.animateOnlyOnFocus 146 | : animateOnlyOnFocus // ignore: cast_nullable_to_non_nullable 147 | as bool?, 148 | )); 149 | } 150 | } 151 | 152 | /// @nodoc 153 | @JsonSerializable() 154 | class _$_Config with DiagnosticableTreeMixin implements _Config { 155 | const _$_Config( 156 | {@JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 157 | this.color, 158 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 159 | this.backgroundColor, 160 | this.connection, 161 | this.smoothScroll, 162 | this.animateOnlyOnFocus}); 163 | 164 | factory _$_Config.fromJson(Map json) => 165 | _$$_ConfigFromJson(json); 166 | 167 | @override 168 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 169 | final Color? color; 170 | @override 171 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 172 | final Color? backgroundColor; 173 | @override 174 | final String? connection; 175 | @override 176 | final bool? smoothScroll; 177 | @override 178 | final bool? animateOnlyOnFocus; 179 | 180 | @override 181 | String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { 182 | return 'Config(color: $color, backgroundColor: $backgroundColor, connection: $connection, smoothScroll: $smoothScroll, animateOnlyOnFocus: $animateOnlyOnFocus)'; 183 | } 184 | 185 | @override 186 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { 187 | super.debugFillProperties(properties); 188 | properties 189 | ..add(DiagnosticsProperty('type', 'Config')) 190 | ..add(DiagnosticsProperty('color', color)) 191 | ..add(DiagnosticsProperty('backgroundColor', backgroundColor)) 192 | ..add(DiagnosticsProperty('connection', connection)) 193 | ..add(DiagnosticsProperty('smoothScroll', smoothScroll)) 194 | ..add(DiagnosticsProperty('animateOnlyOnFocus', animateOnlyOnFocus)); 195 | } 196 | 197 | @override 198 | bool operator ==(dynamic other) { 199 | return identical(this, other) || 200 | (other.runtimeType == runtimeType && 201 | other is _$_Config && 202 | const DeepCollectionEquality().equals(other.color, color) && 203 | const DeepCollectionEquality() 204 | .equals(other.backgroundColor, backgroundColor) && 205 | (identical(other.connection, connection) || 206 | other.connection == connection) && 207 | (identical(other.smoothScroll, smoothScroll) || 208 | other.smoothScroll == smoothScroll) && 209 | (identical(other.animateOnlyOnFocus, animateOnlyOnFocus) || 210 | other.animateOnlyOnFocus == animateOnlyOnFocus)); 211 | } 212 | 213 | @JsonKey(ignore: true) 214 | @override 215 | int get hashCode => Object.hash( 216 | runtimeType, 217 | const DeepCollectionEquality().hash(color), 218 | const DeepCollectionEquality().hash(backgroundColor), 219 | connection, 220 | smoothScroll, 221 | animateOnlyOnFocus); 222 | 223 | @JsonKey(ignore: true) 224 | @override 225 | @pragma('vm:prefer-inline') 226 | _$$_ConfigCopyWith<_$_Config> get copyWith => 227 | __$$_ConfigCopyWithImpl<_$_Config>(this, _$identity); 228 | 229 | @override 230 | Map toJson() { 231 | return _$$_ConfigToJson( 232 | this, 233 | ); 234 | } 235 | } 236 | 237 | abstract class _Config implements Config { 238 | const factory _Config( 239 | {@JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 240 | final Color? color, 241 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 242 | final Color? backgroundColor, 243 | final String? connection, 244 | final bool? smoothScroll, 245 | final bool? animateOnlyOnFocus}) = _$_Config; 246 | 247 | factory _Config.fromJson(Map json) = _$_Config.fromJson; 248 | 249 | @override 250 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 251 | Color? get color; 252 | @override 253 | @JsonKey(fromJson: _colorFromJson, toJson: _colorToJson) 254 | Color? get backgroundColor; 255 | @override 256 | String? get connection; 257 | @override 258 | bool? get smoothScroll; 259 | @override 260 | bool? get animateOnlyOnFocus; 261 | @override 262 | @JsonKey(ignore: true) 263 | _$$_ConfigCopyWith<_$_Config> get copyWith => 264 | throw _privateConstructorUsedError; 265 | } 266 | -------------------------------------------------------------------------------- /lib/widgets/side_view.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: prefer_const_literals_to_create_immutables, prefer_const_constructors 2 | 3 | import 'dart:convert'; 4 | import 'dart:io'; 5 | 6 | import 'package:dotted_border/dotted_border.dart'; 7 | import 'package:file_picker/file_picker.dart'; 8 | import 'package:flarrent/models/filters.dart'; 9 | import 'package:flarrent/models/torrent.dart'; 10 | import 'package:flarrent/state/filters.dart'; 11 | import 'package:flarrent/state/torrents.dart'; 12 | import 'package:flarrent/utils/capitalize_string.dart'; 13 | import 'package:flarrent/utils/use_values_changed.dart'; 14 | import 'package:flutter/material.dart'; 15 | import 'package:flutter/services.dart'; 16 | import 'package:flutter_hooks/flutter_hooks.dart'; 17 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 18 | 19 | class SideView extends HookConsumerWidget { 20 | const SideView({super.key}); 21 | @override 22 | Widget build(BuildContext context, WidgetRef ref) { 23 | final theme = Theme.of(context); 24 | return ListView( 25 | padding: const EdgeInsets.all(10), 26 | children: [ 27 | _Border( 28 | title: 'Connection', 29 | child: SizedBox( 30 | width: double.infinity, 31 | child: Column( 32 | children: [ 33 | SizedBox(height: 6), 34 | Consumer( 35 | builder: (context, ref, child) { 36 | final connectionString = ref.watch(torrentsProvider.select((s) => s.client.connectionString)); 37 | 38 | return Text(connectionString); 39 | }, 40 | ), 41 | SizedBox(height: 6), 42 | TextButton( 43 | onPressed: () async { 44 | final result = await FilePicker.platform.pickFiles( 45 | type: FileType.custom, 46 | allowedExtensions: ['torrent'], 47 | ); 48 | 49 | final futures = >[]; 50 | for (final file in result?.files ?? []) { 51 | if (file.path == null) continue; 52 | futures.add( 53 | ref.read(torrentsProvider.notifier).addTorrentBase64( 54 | Base64Encoder().convert(File(file.path!).readAsBytesSync()), 55 | ), 56 | ); 57 | } 58 | 59 | await Future.wait(futures); 60 | }, 61 | child: Padding( 62 | padding: const EdgeInsets.all(6), 63 | child: Row( 64 | children: [ 65 | Icon(Icons.add), 66 | SizedBox(width: 10), 67 | Text('Add torrent file'), 68 | ], 69 | ), 70 | ), 71 | ), 72 | TextButton( 73 | onPressed: () { 74 | Clipboard.getData('text/plain').then((value) { 75 | final text = value?.text?.trim(); 76 | if (text == null || !text.startsWith('magnet:')) return; 77 | ref.read(torrentsProvider.notifier).addTorrentMagnet(text); 78 | }); 79 | }, 80 | child: Padding( 81 | padding: const EdgeInsets.all(6), 82 | child: Row( 83 | children: [ 84 | Icon(Icons.add), 85 | SizedBox(width: 10), 86 | Text('Add torrent magnet'), 87 | ], 88 | ), 89 | ), 90 | ), 91 | ], 92 | ), 93 | ), 94 | ), 95 | SizedBox(height: 20), 96 | _Border( 97 | title: 'Filter & Sort', 98 | child: Column( 99 | children: [ 100 | HookConsumer( 101 | builder: (context, ref, child) { 102 | final controller = useTextEditingController(text: ref.read(filtersProvider.select((s) => s.query))); 103 | return TextField( 104 | controller: controller, 105 | cursorColor: Colors.white, 106 | decoration: InputDecoration( 107 | labelText: 'Query', 108 | ), 109 | onChanged: (value) { 110 | ref.read(filtersProvider.notifier).update((s) => s.copyWith(query: value)); 111 | }, 112 | ); 113 | }, 114 | ), 115 | SizedBox(height: 20), 116 | Consumer( 117 | builder: (context, ref, child) { 118 | final states = ref.watch(filtersProvider.select((s) => s.states)); 119 | return Wrap( 120 | spacing: 10, 121 | runSpacing: 10, 122 | alignment: WrapAlignment.center, 123 | children: TorrentState.values.map((e) { 124 | final selected = states.contains(e); 125 | return TextButton( 126 | style: TextButton.styleFrom( 127 | padding: const EdgeInsets.all(10), 128 | backgroundColor: selected ? theme.colorScheme.surface : theme.colorScheme.surfaceVariant, 129 | shape: RoundedRectangleBorder( 130 | borderRadius: BorderRadius.circular(100), 131 | ), 132 | ), 133 | onPressed: () { 134 | ref.read(filtersProvider.notifier).update((s) { 135 | if (selected) { 136 | return s.copyWith(states: s.states.where((state) => state != e).toList()); 137 | } else { 138 | return s.copyWith(states: {...s.states, e}.toList()); 139 | } 140 | }); 141 | }, 142 | child: Text(capitalizeString(e.name)), 143 | ); 144 | }).toList(), 145 | ); 146 | }, 147 | ), 148 | SizedBox(height: 20), 149 | Row( 150 | mainAxisAlignment: MainAxisAlignment.spaceBetween, 151 | children: [ 152 | Consumer( 153 | builder: (context, ref, child) { 154 | return DropdownMenu( 155 | initialSelection: 156 | ref.read(filtersProvider.select((s) => s.sortBy)), // Not sure how correct it is to do this. 157 | onSelected: (value) { 158 | if (value == null) return; 159 | ref.read(filtersProvider.notifier).update((s) => s.copyWith(sortBy: value)); 160 | }, 161 | dropdownMenuEntries: [ 162 | DropdownMenuEntry(label: 'Name', value: SortBy.name), 163 | DropdownMenuEntry(label: 'Download speed', value: SortBy.downloadSpeed), 164 | DropdownMenuEntry(label: 'Upload speed', value: SortBy.uploadSpeed), 165 | DropdownMenuEntry(label: 'Size', value: SortBy.size), 166 | DropdownMenuEntry(label: 'Added on', value: SortBy.addedOn), 167 | DropdownMenuEntry(label: 'Completed on', value: SortBy.completedOn), 168 | ], 169 | ); 170 | }, 171 | ), 172 | Consumer( 173 | builder: (context, ref, child) { 174 | final ascending = ref.watch(filtersProvider.select((s) => s.ascending)); 175 | return AnimatedRotation( 176 | turns: ascending ? 0 : 0.5, 177 | duration: const Duration(milliseconds: 300), 178 | curve: Curves.easeOutExpo, 179 | child: IconButton( 180 | icon: const Icon(Icons.arrow_upward), 181 | splashRadius: 20, 182 | onPressed: () { 183 | ref.read(filtersProvider.notifier).update((s) => s.copyWith(ascending: !s.ascending)); 184 | }, 185 | ), 186 | ); 187 | }, 188 | ), 189 | ], 190 | ), 191 | ], 192 | ), 193 | ), 194 | ], 195 | ); 196 | } 197 | } 198 | 199 | class _InvertedClipper extends CustomClipper { 200 | const _InvertedClipper(this.removedRect); 201 | 202 | final Rect removedRect; 203 | 204 | @override 205 | Path getClip(Size size) { 206 | return Path() 207 | ..addRect(Rect.fromLTWH(-1, -1, size.width + 2, size.height + 2)) 208 | ..addRect(removedRect) 209 | ..fillType = PathFillType.evenOdd; 210 | } 211 | 212 | @override 213 | bool shouldReclip(CustomClipper oldClipper) => true; 214 | } 215 | 216 | class _Border extends HookConsumerWidget { 217 | const _Border({ 218 | required this.title, 219 | required this.child, 220 | }); 221 | 222 | final String title; 223 | final Widget child; 224 | 225 | @override 226 | Widget build(BuildContext context, WidgetRef ref) { 227 | final removedRect = useRef(Rect.zero); 228 | 229 | useValuesChanged( 230 | [title], 231 | firstTime: true, 232 | callback: () { 233 | removedRect.value = Rect.fromLTWH(5, 0, _textSize(title).width, 12); 234 | return; 235 | }, 236 | ); 237 | 238 | return Stack( 239 | children: [ 240 | ClipPath( 241 | clipper: _InvertedClipper(removedRect.value), 242 | child: Padding( 243 | padding: const EdgeInsets.only(top: 10), 244 | child: DottedBorder( 245 | borderType: BorderType.RRect, 246 | dashPattern: [5, 3], 247 | radius: Radius.circular(12), 248 | padding: EdgeInsets.all(10), 249 | color: Colors.grey.shade700.withOpacity(0.3), 250 | strokeWidth: 2, 251 | child: child, 252 | ), 253 | ), 254 | ), 255 | Positioned( 256 | left: 10, 257 | child: Text(title, style: TextStyle(color: Colors.grey.shade500)), 258 | ), 259 | ], 260 | ); 261 | } 262 | } 263 | 264 | Size _textSize(String text, [TextStyle? style]) { 265 | final textPainter = TextPainter( 266 | text: TextSpan(text: text, style: style), 267 | maxLines: 1, 268 | textDirection: TextDirection.ltr, 269 | )..layout(); 270 | return textPainter.size; 271 | } 272 | -------------------------------------------------------------------------------- /lib/state/transmission.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flarrent/models/torrent.dart'; 4 | import 'package:flarrent/state/torrents.dart'; 5 | import 'package:flarrent/utils/timer_stream.dart'; 6 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 7 | import 'package:transmission_rpc/transmission_rpc.dart'; 8 | 9 | class TransmissionTorrents extends Torrents { 10 | TransmissionTorrents( 11 | StateNotifierProviderRef ref, { 12 | required this.transmission, 13 | }) : super( 14 | ref, 15 | const TorrentsState( 16 | torrents: [], 17 | uploadSpeeds: {}, 18 | downloadSpeeds: {}, 19 | quickTorrents: [], 20 | client: ClientState( 21 | downloadSpeedBytesPerSecond: 0, 22 | uploadSpeedBytesPerSecond: 0, 23 | downloadLimitBytesPerSecond: null, 24 | uploadLimitBytesPerSecond: null, 25 | alternativeSpeedLimitsEnabled: false, 26 | connectionString: 'Transmission', 27 | ), 28 | ), 29 | ) { 30 | _streams = [ 31 | timerStream(const Duration(milliseconds: 500)).listen((_) => _getTransmissionMainTorrents()), 32 | timerStream(const Duration(milliseconds: 500)).listen((_) => _getTransmissionQuickTorrents()), 33 | timerStream(const Duration(seconds: 2)).listen((_) => _getTransmissionSession()), 34 | timerStream(const Duration(seconds: 2)).listen((_) async { 35 | if (_lastSession?.downloadDir == null) return; 36 | 37 | final freeSpace = await transmission.freeSpace(_lastSession!.downloadDir!); 38 | 39 | state = state.copyWith( 40 | client: state.client.copyWith( 41 | freeSpaceBytes: freeSpace.sizeBytes, 42 | ), 43 | ); 44 | }), 45 | ]; 46 | } 47 | 48 | final Transmission transmission; 49 | List _mainTorrentIds = []; 50 | late final List> _streams; 51 | TransmissionSession? _lastSession; 52 | 53 | @override 54 | Future pause(List ids) async { 55 | if (ids.isEmpty) return; 56 | await transmission.stopTorrent(ids: ids); 57 | } 58 | 59 | @override 60 | Future addTorrentMagnet(String magnet) async { 61 | await transmission.addTorrent(filename: magnet); 62 | unawaited(_getTransmissionQuickTorrents()); 63 | } 64 | 65 | @override 66 | Future addTorrentBase64(String base64) async { 67 | await transmission.addTorrent(metainfo: base64); 68 | unawaited(_getTransmissionQuickTorrents()); 69 | } 70 | 71 | @override 72 | Future resume(List ids) async { 73 | if (ids.isEmpty) return; 74 | await transmission.startTorrent(ids: ids); 75 | unawaited(_getTransmissionQuickTorrents()); 76 | unawaited(_getTransmissionMainTorrents()); 77 | } 78 | 79 | @override 80 | Future deleteTorrent(List ids, {required bool deleteData}) async { 81 | if (ids.isEmpty) return; 82 | await transmission.removeTorrent(ids: ids, deleteLocalData: deleteData); 83 | unawaited(_getTransmissionQuickTorrents()); 84 | unawaited(_getTransmissionMainTorrents()); 85 | } 86 | 87 | @override 88 | Future changePriority(List ids, TorrentPriority newPriority) async { 89 | if (ids.isEmpty) return; 90 | await transmission.setTorrents(ids: ids, bandwidthPriority: _priorityToTransPriority(newPriority)); 91 | unawaited(_getTransmissionQuickTorrents()); 92 | unawaited(_getTransmissionMainTorrents()); 93 | } 94 | 95 | @override 96 | Future changeFilePriority(int torrentId, List files, TorrentPriority newPriority) async { 97 | if (files.isEmpty) return; 98 | switch (newPriority) { 99 | case TorrentPriority.low: 100 | await transmission.setTorrents( 101 | ids: [torrentId], 102 | priorityLow: files, 103 | ); 104 | break; 105 | case TorrentPriority.normal: 106 | await transmission.setTorrents( 107 | ids: [torrentId], 108 | priorityNormal: files, 109 | ); 110 | break; 111 | case TorrentPriority.high: 112 | await transmission.setTorrents( 113 | ids: [torrentId], 114 | priorityHigh: files, 115 | ); 116 | break; 117 | } 118 | unawaited(_getTransmissionQuickTorrents()); 119 | unawaited(_getTransmissionMainTorrents()); 120 | } 121 | 122 | @override 123 | Future setAlternativeLimits({required bool enabled}) async { 124 | await transmission.setSession(altSpeedEnabled: enabled); 125 | 126 | unawaited(_getTransmissionSession()); 127 | } 128 | 129 | @override 130 | Future setTorrentsLimit(List ids, int? downloadBytesLimit, int? uploadBytesLimit) async { 131 | if (ids.isEmpty) return; 132 | await transmission.setTorrents( 133 | ids: ids, 134 | downloadLimit: downloadBytesLimit != null ? downloadBytesLimit ~/ 1024 : null, 135 | uploadLimit: uploadBytesLimit != null ? uploadBytesLimit ~/ 1024 : null, 136 | downloadLimited: downloadBytesLimit != null, 137 | uploadLimited: uploadBytesLimit != null, 138 | ); 139 | unawaited(_getTransmissionQuickTorrents()); 140 | unawaited(_getTransmissionMainTorrents()); 141 | } 142 | 143 | @override 144 | Future pauseFiles(int torrentId, List files) async { 145 | if (files.isEmpty) return; 146 | await transmission.setTorrents(ids: [torrentId], filesUnwanted: files); 147 | unawaited(_getTransmissionQuickTorrents()); 148 | unawaited(_getTransmissionMainTorrents()); 149 | } 150 | 151 | @override 152 | Future resumeFiles(int torrentId, List files) async { 153 | if (files.isEmpty) return; 154 | await transmission.setTorrents(ids: [torrentId], filesWanted: files); 155 | unawaited(_getTransmissionQuickTorrents()); 156 | unawaited(_getTransmissionMainTorrents()); 157 | } 158 | 159 | @override 160 | Future setMainTorrents(List torrentIds) async { 161 | _mainTorrentIds = torrentIds; 162 | await _getTransmissionMainTorrents(); 163 | } 164 | 165 | @override 166 | void dispose() { 167 | for (final stream in _streams) { 168 | stream.cancel(); 169 | } 170 | super.dispose(); 171 | } 172 | 173 | TorrentState _transStatusToState(TransmissionTorrent torrent) { 174 | if (torrent.error != 0) return TorrentState.error; 175 | return switch (torrent.status!) { 176 | TransmissionTorrentStatus.downloading => TorrentState.downloading, 177 | TransmissionTorrentStatus.stopped => torrent.percentDone == 1 ? TorrentState.completed : TorrentState.paused, 178 | TransmissionTorrentStatus.seeding => TorrentState.seeding, 179 | TransmissionTorrentStatus.verifying => TorrentState.downloading, 180 | TransmissionTorrentStatus.queuedToSeed => TorrentState.queued, 181 | TransmissionTorrentStatus.queuedToVerify => TorrentState.queued, 182 | TransmissionTorrentStatus.queuedToDownload => TorrentState.queued, 183 | }; 184 | } 185 | 186 | TorrentPriority _transPriorityToPriority(TransmissionPriority priority) { 187 | return switch (priority) { 188 | TransmissionPriority.low => TorrentPriority.low, 189 | TransmissionPriority.normal => TorrentPriority.normal, 190 | TransmissionPriority.high => TorrentPriority.high, 191 | }; 192 | } 193 | 194 | TransmissionPriority _priorityToTransPriority(TorrentPriority priority) { 195 | return switch (priority) { 196 | TorrentPriority.low => TransmissionPriority.low, 197 | TorrentPriority.normal => TransmissionPriority.normal, 198 | TorrentPriority.high => TransmissionPriority.high, 199 | }; 200 | } 201 | 202 | Future _getTransmissionSession() async { 203 | final res = await Future.wait([transmission.getSession(), transmission.getSessionStats()]); 204 | final session = res[0] as TransmissionSession; 205 | final stats = res[1] as TransmissionSessionStats; 206 | _lastSession = session; 207 | 208 | int? downLimit; 209 | int? upLimit; 210 | 211 | if (session.altSpeedEnabled!) { 212 | downLimit = session.altSpeedDown! * 1024; 213 | upLimit = session.altSpeedUp! * 1024; 214 | } else { 215 | downLimit = session.speedLimitDownEnabled! ? session.speedLimitDown! * 1024 : null; 216 | upLimit = session.speedLimitUpEnabled! ? session.speedLimitUp! * 1024 : null; 217 | } 218 | state = state.copyWith( 219 | client: state.client.copyWith( 220 | downloadSpeedBytesPerSecond: stats.downloadSpeed, 221 | uploadSpeedBytesPerSecond: stats.uploadSpeed, 222 | downloadLimitBytesPerSecond: downLimit, 223 | uploadLimitBytesPerSecond: upLimit, 224 | alternativeSpeedLimitsEnabled: session.altSpeedEnabled!, 225 | connectionString: 'Transmission ${session.version}', 226 | ), 227 | ); 228 | } 229 | 230 | Future _getTransmissionQuickTorrents() async { 231 | final torrents = await transmission.getTorrents( 232 | fields: { 233 | TransmissionTorrentGetFields.id, 234 | TransmissionTorrentGetFields.name, 235 | TransmissionTorrentGetFields.sizeWhenDone, 236 | TransmissionTorrentGetFields.percentDone, 237 | TransmissionTorrentGetFields.totalSize, 238 | TransmissionTorrentGetFields.eta, 239 | TransmissionTorrentGetFields.rateDownload, 240 | TransmissionTorrentGetFields.downloadLimited, 241 | TransmissionTorrentGetFields.rateUpload, 242 | TransmissionTorrentGetFields.uploadLimited, 243 | TransmissionTorrentGetFields.status, 244 | TransmissionTorrentGetFields.error, 245 | TransmissionTorrentGetFields.bandwidthPriority, 246 | TransmissionTorrentGetFields.addedDate, 247 | TransmissionTorrentGetFields.doneDate, 248 | }, 249 | ); 250 | 251 | state = state.copyWith( 252 | downloadSpeeds: torrents.fold( 253 | {}, 254 | (map, torrent) { 255 | return { 256 | ...map, 257 | torrent.id!: (state.downloadSpeeds[torrent.id] ?? List.generate(39, (index) => 0)).sublist(1) 258 | ..add(torrent.rateDownload!), 259 | }; 260 | }, 261 | ), 262 | uploadSpeeds: torrents.fold( 263 | {}, 264 | (map, torrent) { 265 | return { 266 | ...map, 267 | torrent.id!: (state.uploadSpeeds[torrent.id] ?? List.generate(39, (index) => 0)).sublist(1) 268 | ..add(torrent.rateUpload!), 269 | }; 270 | }, 271 | ), 272 | quickTorrents: torrents.map( 273 | (torrent) { 274 | return TorrentQuickData( 275 | id: torrent.id!, 276 | name: torrent.name!, 277 | downloadedBytes: (torrent.sizeWhenDone! * (torrent.percentDone!)).floor(), 278 | sizeToDownloadBytes: torrent.sizeWhenDone!, 279 | sizeBytes: torrent.totalSize!, 280 | estimatedTimeLeft: torrent.eta!, 281 | downloadBytesPerSecond: torrent.rateDownload!, 282 | downloadLimited: torrent.downloadLimited!, 283 | uploadBytesPerSecond: torrent.rateUpload!, 284 | uploadLimited: torrent.uploadLimited!, 285 | state: _transStatusToState(torrent), 286 | priority: _transPriorityToPriority(torrent.bandwidthPriority!), 287 | addedOn: torrent.addedDate, 288 | completedOn: torrent.doneDate, 289 | ); 290 | }, 291 | ).toList(), 292 | ); 293 | } 294 | 295 | Future _getTransmissionMainTorrents() async { 296 | final torrents = await transmission.getTorrents( 297 | ids: _mainTorrentIds, 298 | fields: { 299 | TransmissionTorrentGetFields.id, 300 | TransmissionTorrentGetFields.name, 301 | TransmissionTorrentGetFields.sizeWhenDone, 302 | TransmissionTorrentGetFields.percentDone, 303 | TransmissionTorrentGetFields.totalSize, 304 | TransmissionTorrentGetFields.eta, 305 | TransmissionTorrentGetFields.rateDownload, 306 | TransmissionTorrentGetFields.downloadLimited, 307 | TransmissionTorrentGetFields.rateUpload, 308 | TransmissionTorrentGetFields.uploadLimited, 309 | TransmissionTorrentGetFields.status, 310 | TransmissionTorrentGetFields.error, 311 | TransmissionTorrentGetFields.bandwidthPriority, 312 | TransmissionTorrentGetFields.addedDate, 313 | TransmissionTorrentGetFields.doneDate, 314 | TransmissionTorrentGetFields.magnetLink, 315 | TransmissionTorrentGetFields.torrentFile, 316 | TransmissionTorrentGetFields.downloadLimit, 317 | TransmissionTorrentGetFields.uploadLimit, 318 | TransmissionTorrentGetFields.uploadedEver, 319 | TransmissionTorrentGetFields.downloadedEver, 320 | TransmissionTorrentGetFields.uploadRatio, 321 | TransmissionTorrentGetFields.files, 322 | TransmissionTorrentGetFields.fileStats, 323 | TransmissionTorrentGetFields.priorities, 324 | TransmissionTorrentGetFields.downloadDir, 325 | TransmissionTorrentGetFields.activityDate, 326 | TransmissionTorrentGetFields.secondsSeeding, 327 | TransmissionTorrentGetFields.secondsDownloading, 328 | }, 329 | ); 330 | 331 | state = state.copyWith( 332 | torrents: torrents.map( 333 | (torrent) { 334 | return TorrentData( 335 | id: torrent.id!, 336 | name: torrent.name!, 337 | downloadedBytes: (torrent.sizeWhenDone! * (torrent.percentDone!)).floor(), 338 | sizeToDownloadBytes: torrent.sizeWhenDone!, 339 | sizeBytes: torrent.totalSize!, 340 | estimatedTimeLeft: torrent.eta!, 341 | downloadBytesPerSecond: torrent.rateDownload!, 342 | state: _transStatusToState(torrent), 343 | downloadLimited: torrent.downloadLimited!, 344 | uploadLimited: torrent.uploadLimited!, 345 | magnet: torrent.magnetLink, 346 | torrentFileLocation: torrent.torrentFile, 347 | downloadLimitBytesPerSecond: torrent.downloadLimit! * 1024, 348 | uploadLimitBytesPerSecond: torrent.uploadLimit! * 1024, 349 | priority: _transPriorityToPriority(torrent.bandwidthPriority!), 350 | uploadedEverBytes: torrent.uploadedEver, 351 | downloadedEverBytes: torrent.downloadedEver, 352 | uploadBytesPerSecond: torrent.rateUpload!, 353 | ratio: torrent.uploadRatio!, 354 | files: torrent.files!.asMap().entries.map( 355 | (entry) { 356 | final i = entry.key; 357 | final file = entry.value; 358 | final stats = torrent.fileStats![i]; 359 | return TorrentFileData( 360 | name: file.name, 361 | downloadedBytes: file.bytesCompleted, 362 | sizeBytes: file.length, 363 | priority: _transPriorityToPriority(torrent.priorities![i]), 364 | state: stats.wanted 365 | ? file.bytesCompleted == file.length 366 | ? TorrentState.completed 367 | : TorrentState.downloading 368 | : TorrentState.paused, 369 | ); 370 | }, 371 | ).toList(), 372 | completedOn: torrent.doneDate, 373 | addedOn: torrent.addedDate, 374 | peers: [''], 375 | trackers: [''], 376 | location: torrent.downloadDir, 377 | lastActivity: torrent.activityDate, 378 | timeDownloading: 379 | (torrent.secondsDownloading ?? 0) > 0 ? Duration(seconds: torrent.secondsDownloading!) : null, 380 | timeSeeding: (torrent.secondsSeeding ?? 0) > 0 ? Duration(seconds: torrent.secondsSeeding!) : null, 381 | ); 382 | }, 383 | ).toList(), 384 | ); 385 | } 386 | } 387 | -------------------------------------------------------------------------------- /lib/widgets/torrent/torrent.dart: -------------------------------------------------------------------------------- 1 | import 'package:flarrent/models/torrent.dart'; 2 | import 'package:flarrent/utils/rect_custom_clipper.dart'; 3 | import 'package:flarrent/utils/safe_divide.dart'; 4 | import 'package:flarrent/utils/units.dart'; 5 | import 'package:flarrent/widgets/common/button.dart'; 6 | import 'package:flutter/material.dart'; 7 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 8 | 9 | Color _stateToColor(TorrentState state) => switch (state) { 10 | TorrentState.queued => Colors.yellowAccent, 11 | TorrentState.paused => Colors.grey, 12 | TorrentState.error => Colors.pinkAccent, 13 | TorrentState.downloading => Colors.greenAccent, 14 | TorrentState.seeding => Colors.purpleAccent, 15 | TorrentState.completed => Colors.lightBlue, 16 | }; 17 | 18 | Color torrentPriorityToColor(TorrentPriority priority) => switch (priority) { 19 | TorrentPriority.low => const Color.fromARGB(255, 150, 107, 159), 20 | TorrentPriority.normal => Colors.white, 21 | TorrentPriority.high => Colors.lightBlue, 22 | }; 23 | 24 | class _Shell extends StatelessWidget { 25 | const _Shell({ 26 | required this.borderRadius, 27 | required this.progress, 28 | required this.color, 29 | required this.selected, 30 | required this.child, 31 | // ignore: unused_element 32 | super.key, 33 | }); 34 | 35 | final double borderRadius; 36 | final double progress; 37 | final Color color; 38 | final bool selected; 39 | final Widget child; 40 | 41 | @override 42 | Widget build(BuildContext context) { 43 | final theme = Theme.of(context); 44 | 45 | return Stack( 46 | children: [ 47 | Positioned.fill( 48 | child: Container( 49 | decoration: BoxDecoration( 50 | borderRadius: BorderRadius.circular(borderRadius), 51 | border: Border.all( 52 | color: theme.colorScheme.onSecondary.withOpacity(0.2), 53 | ), 54 | color: selected ? theme.colorScheme.onSecondary.withOpacity(0.2) : null, 55 | ), 56 | ), 57 | ), 58 | 59 | Positioned.fill( 60 | child: ClipRect( 61 | clipper: RectCustomClipper( 62 | (size) => Rect.fromLTWH( 63 | 0, 64 | size.height - 5, 65 | size.width, 66 | size.height - 5, 67 | ), 68 | ), 69 | child: DecoratedBox( 70 | decoration: BoxDecoration( 71 | borderRadius: BorderRadius.circular(borderRadius), 72 | border: Border.all(color: color.withOpacity(color.alpha / 255 * 0.3)), 73 | ), 74 | ), 75 | ), 76 | ), 77 | 78 | // Progress 79 | Positioned.fill( 80 | child: ClipRect( 81 | clipper: RectCustomClipper( 82 | (size) => Rect.fromLTWH( 83 | 0, 84 | size.height - 5, 85 | size.width * progress, 86 | size.height - 5, 87 | ), 88 | ), 89 | child: DecoratedBox( 90 | decoration: BoxDecoration( 91 | borderRadius: BorderRadius.circular(borderRadius), 92 | border: Border.all(color: color), 93 | ), 94 | ), 95 | ), 96 | ), 97 | Positioned.fill( 98 | child: ClipRRect( 99 | borderRadius: BorderRadius.circular(borderRadius), 100 | child: CustomPaint( 101 | painter: _GlowPainter( 102 | color: color.withOpacity(0.7), 103 | progress: progress, 104 | ), 105 | ), 106 | ), 107 | ), 108 | child, 109 | ], 110 | ); 111 | } 112 | } 113 | 114 | class TorrentTile extends HookConsumerWidget { 115 | const TorrentTile({ 116 | super.key, 117 | required this.quickData, 118 | required this.selected, 119 | this.onPressed, 120 | }); 121 | 122 | final TorrentQuickData quickData; 123 | final void Function()? onPressed; 124 | final bool selected; 125 | 126 | @override 127 | Widget build(BuildContext context, WidgetRef ref) { 128 | const borderRadius = 10.0; 129 | final theme = Theme.of(context); 130 | final progress = safeDivide(quickData.downloadedBytes / quickData.sizeToDownloadBytes, 1); 131 | final color = _stateToColor(quickData.state); 132 | 133 | return DecoratedBox( 134 | decoration: BoxDecoration( 135 | borderRadius: BorderRadius.circular(borderRadius), 136 | boxShadow: [ 137 | BoxShadow( 138 | color: theme.shadowColor, 139 | blurRadius: 22, 140 | blurStyle: BlurStyle.outer, 141 | ), 142 | ], 143 | ), 144 | child: _Shell( 145 | borderRadius: borderRadius, 146 | progress: progress, 147 | color: color, 148 | selected: selected, 149 | child: InkButton( 150 | borderRadius: BorderRadius.circular(borderRadius), 151 | onPressed: onPressed ?? () {}, 152 | child: Container( 153 | padding: const EdgeInsets.all(10), 154 | child: Column( 155 | crossAxisAlignment: CrossAxisAlignment.start, 156 | children: [ 157 | RichText( 158 | text: TextSpan( 159 | children: [ 160 | TextSpan( 161 | text: quickData.name, 162 | style: TextStyle( 163 | fontFamily: 'Roboto', 164 | color: theme.colorScheme.onSecondary, 165 | fontSize: 14, 166 | fontWeight: FontWeight.bold, 167 | ), 168 | ), 169 | ], 170 | ), 171 | ), 172 | const SizedBox( 173 | height: 4, 174 | ), 175 | Row( 176 | children: [ 177 | Text( 178 | '''${_stringBytesOfDoneWithUnits(quickData.downloadedBytes, quickData.sizeToDownloadBytes, quickData.sizeBytes)} ${(progress * 100).floor()}%''', 179 | style: TextStyle( 180 | fontSize: 14, 181 | color: ColorTween(begin: theme.colorScheme.onSecondary, end: Colors.black).transform(0.4), 182 | ), 183 | ), 184 | const Spacer(), 185 | _PriorityIcon( 186 | quickData.priority, 187 | size: 16, 188 | ), 189 | const SizedBox( 190 | width: 5, 191 | ), 192 | Builder( 193 | builder: (context) { 194 | return RichText( 195 | text: TextSpan( 196 | style: const TextStyle( 197 | fontSize: 14, 198 | color: Color.fromARGB(255, 62, 107, 159), 199 | ), 200 | children: [ 201 | if (quickData.state == TorrentState.downloading && 202 | quickData.estimatedTimeLeft.inSeconds > 0) 203 | TextSpan( 204 | text: formatDuration( 205 | quickData.estimatedTimeLeft, 206 | ), 207 | ), 208 | if (quickData.state == TorrentState.downloading || 209 | quickData.state == TorrentState.seeding) 210 | const WidgetSpan(child: SizedBox(width: 10)), 211 | if (quickData.state == TorrentState.seeding) 212 | TextSpan( 213 | text: '${stringBytesWithUnits(quickData.uploadBytesPerSecond)}/s', 214 | style: quickData.uploadLimited 215 | ? const TextStyle( 216 | color: Color.fromARGB(255, 150, 107, 159), 217 | ) 218 | : null, 219 | ), 220 | if (quickData.state == TorrentState.seeding) 221 | WidgetSpan( 222 | child: Icon( 223 | Icons.arrow_upward, 224 | size: 15, 225 | color: quickData.uploadLimited 226 | ? const Color.fromARGB(255, 150, 107, 159) 227 | : const Color.fromARGB(255, 62, 107, 159), 228 | ), 229 | ), 230 | if (quickData.state == TorrentState.downloading) 231 | TextSpan( 232 | text: '${stringBytesWithUnits(quickData.downloadBytesPerSecond)}/s', 233 | style: quickData.downloadLimited 234 | ? const TextStyle( 235 | color: Color.fromARGB(255, 150, 107, 159), 236 | ) 237 | : null, 238 | ), 239 | if (quickData.state == TorrentState.downloading) 240 | WidgetSpan( 241 | child: Icon( 242 | Icons.arrow_downward, 243 | size: 15, 244 | color: quickData.downloadLimited 245 | ? const Color.fromARGB(255, 150, 107, 159) 246 | : const Color.fromARGB(255, 62, 107, 159), 247 | ), 248 | ), 249 | ], 250 | ), 251 | ); 252 | }, 253 | ), 254 | ], 255 | ), 256 | ], 257 | ), 258 | ), 259 | ), 260 | ), 261 | ); 262 | } 263 | } 264 | 265 | class TorrentFileTile extends HookConsumerWidget { 266 | const TorrentFileTile({ 267 | super.key, 268 | required this.torrentState, 269 | required this.fileData, 270 | required this.selected, 271 | this.titleColor, 272 | this.onPressed, 273 | }); 274 | 275 | final TorrentState torrentState; 276 | final TorrentFileData fileData; 277 | final bool selected; 278 | final Color? titleColor; 279 | final void Function()? onPressed; 280 | 281 | @override 282 | Widget build(BuildContext context, WidgetRef ref) { 283 | const borderRadius = 5.0; 284 | final theme = Theme.of(context); 285 | final progress = safeDivide(fileData.downloadedBytes / fileData.sizeBytes, 1); 286 | final color = _stateToColor( 287 | fileData.state == TorrentState.downloading && torrentState != TorrentState.downloading 288 | ? TorrentState.paused 289 | : fileData.state, 290 | ); 291 | 292 | return _Shell( 293 | borderRadius: borderRadius, 294 | progress: progress < 0.02 ? 0 : progress, 295 | color: color, 296 | selected: selected, 297 | child: Opacity( 298 | opacity: fileData.state == TorrentState.paused ? 0.5 : 1, 299 | child: InkButton( 300 | borderRadius: BorderRadius.circular(borderRadius), 301 | onPressed: onPressed ?? () {}, 302 | child: Container( 303 | padding: const EdgeInsets.all(5), 304 | width: double.infinity, 305 | child: Column( 306 | crossAxisAlignment: CrossAxisAlignment.start, 307 | children: [ 308 | RichText( 309 | text: TextSpan( 310 | children: [ 311 | TextSpan( 312 | text: fileData.name, 313 | style: TextStyle( 314 | fontFamily: 'Roboto', 315 | color: titleColor ?? theme.colorScheme.onSecondary, 316 | fontSize: 12, 317 | fontWeight: FontWeight.bold, 318 | ), 319 | ), 320 | const WidgetSpan( 321 | child: SizedBox( 322 | width: 5, 323 | ), 324 | ), 325 | TextSpan( 326 | text: 327 | '''${_stringBytesOfWithUnits(fileData.downloadedBytes, fileData.sizeBytes)} (${(progress * 100).floor()}%)''', 328 | style: TextStyle( 329 | fontSize: 11, 330 | color: ColorTween(begin: theme.colorScheme.onSecondary, end: Colors.black).transform(0.4), 331 | ), 332 | ), 333 | WidgetSpan( 334 | child: _PriorityIcon( 335 | fileData.priority, 336 | size: 13, 337 | ), 338 | ) 339 | ], 340 | ), 341 | ), 342 | const SizedBox( 343 | height: 2, 344 | ), 345 | ], 346 | ), 347 | ), 348 | ), 349 | ), 350 | ); 351 | // return Container( 352 | // child: ColoredBox(color: Colors.amber), 353 | // ); 354 | } 355 | } 356 | 357 | class _GlowPainter extends CustomPainter { 358 | _GlowPainter({ 359 | required this.color, 360 | required this.progress, 361 | }); 362 | 363 | final Paint _paint = Paint(); 364 | final Color color; 365 | final double progress; 366 | 367 | @override 368 | void paint(Canvas canvas, Size size) { 369 | _paint 370 | ..style = PaintingStyle.stroke 371 | ..strokeWidth = 6 372 | ..color = color 373 | ..maskFilter = const MaskFilter.blur( 374 | BlurStyle.normal, 375 | 10, 376 | ); 377 | canvas.drawLine( 378 | Offset(0, size.height), 379 | Offset(size.width * progress + 10, size.height), 380 | _paint, 381 | ); 382 | } 383 | 384 | @override 385 | bool shouldRepaint(CustomPainter oldDelegate) => oldDelegate != this; 386 | } 387 | 388 | class _PriorityIcon extends StatelessWidget { 389 | const _PriorityIcon(this.priority, {required this.size}); 390 | 391 | final TorrentPriority priority; 392 | final double size; 393 | 394 | @override 395 | Widget build(BuildContext context) { 396 | if (priority == TorrentPriority.normal) return const SizedBox(); 397 | return Icon( 398 | priority == TorrentPriority.high ? Icons.arrow_drop_up : Icons.arrow_drop_down, 399 | color: torrentPriorityToColor(priority), 400 | size: size, 401 | ); 402 | } 403 | } 404 | 405 | 406 | String _stringBytesOfWithUnits(int bytes1, int bytes2, {Unit? unit}) { 407 | final u1 = detectUnit(bytes1).name; 408 | final u2 = detectUnit(bytes2).name; 409 | final b1 = fromBytesToUnit(bytes1, unit: unit); 410 | final b2 = fromBytesToUnit(bytes2, unit: unit); 411 | 412 | if (u1 == u2) { 413 | return '$b1 of $b2 $u2'; 414 | } else { 415 | return '$b1 $u1 of $b2 $u2'; 416 | } 417 | } 418 | 419 | String _stringBytesOfDoneWithUnits(int bytes1, int bytes2, int bytes3, {Unit? unit}) { 420 | if (bytes3 == bytes2) return _stringBytesOfWithUnits(bytes1, bytes2, unit: unit); 421 | 422 | final u1 = detectUnit(bytes1).name; 423 | final u2 = detectUnit(bytes2).name; 424 | final u3 = detectUnit(bytes3).name; 425 | final b1 = fromBytesToUnit(bytes1, unit: unit); 426 | final b2 = fromBytesToUnit(bytes2, unit: unit); 427 | final b3 = fromBytesToUnit(bytes3, unit: unit); 428 | 429 | if (u1 == u2 && u1 == u3) { 430 | return '$b1 of $b2 ($b3) $u2'; 431 | } else if (u1 == u2 && u1 != u3) { 432 | return '$b1 of $b2 $u2 ($b3 $u3)'; 433 | } else if (u1 != u2 && u1 == u3) { 434 | return '$b1 $u1 of $b2 ($b3) $u2 '; 435 | } else { 436 | return '$b1 $u1 of $b2 $u2 ($b3 $u3)'; 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /lib/widgets/smooth_graph/smooth_graph.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: implementation_imports, invalid_use_of_visible_for_testing_member 2 | 3 | import 'dart:math'; 4 | 5 | import 'package:fl_chart/fl_chart.dart'; 6 | import 'package:fl_chart/src/chart/base/base_chart/base_chart_painter.dart'; 7 | import 'package:fl_chart/src/chart/line_chart/line_chart_painter.dart'; 8 | import 'package:flarrent/state/config.dart'; 9 | import 'package:flarrent/state/focused.dart'; 10 | import 'package:flarrent/utils/rect_custom_clipper.dart'; 11 | import 'package:flarrent/utils/timer_stream.dart'; 12 | import 'package:flarrent/utils/use_values_changed.dart'; 13 | import 'package:flarrent/widgets/smooth_graph/get_y_from_x.dart'; 14 | import 'package:flutter/material.dart'; 15 | import 'package:flutter/services.dart'; 16 | import 'package:flutter_hooks/flutter_hooks.dart'; 17 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 18 | 19 | class SmoothChart extends HookConsumerWidget { 20 | const SmoothChart({ 21 | super.key, 22 | required this.getInitialPointsY, 23 | required this.getNextPointY, 24 | required this.tint, 25 | }); 26 | 27 | final double Function(int index) getInitialPointsY; 28 | final double Function() getNextPointY; 29 | final Color tint; 30 | 31 | @override 32 | Widget build(BuildContext context, WidgetRef ref) { 33 | final focused = ref.watch(focusedProvider).valueOrNull ?? true; 34 | final animateOnlyOnFocus = ref.watch(configProvider.select((c) => c.valueOrNull!.animateOnlyOnFocus!)); 35 | final animate = (!animateOnlyOnFocus) || focused; 36 | 37 | const pointsNum = 35; 38 | const pointSpace = 1 / pointsNum; 39 | final moveAC = useAnimationController(duration: const Duration(milliseconds: 500)); 40 | final maxYAC = useAnimationController( 41 | initialValue: 1, 42 | upperBound: double.infinity, 43 | ); 44 | final lastT = useRef(0); 45 | 46 | final points = useState([]); 47 | 48 | final mod = useRef(1); 49 | 50 | useEffect( 51 | () { 52 | void moveACListener() { 53 | if (moveAC.status == AnimationStatus.completed) { 54 | lastT.value = 0; 55 | if (animate) { 56 | moveAC 57 | ..reset() 58 | ..forward(); 59 | } 60 | points.value = points.value.skip(1).map((p) => p.copyWith(x: p.x - pointSpace)).toList() + 61 | [ 62 | FlSpot( 63 | 1 + pointSpace * 2, 64 | getNextPointY(), 65 | ), 66 | ]; 67 | maxYAC.animateTo( 68 | max( 69 | 1, 70 | ([...points.value]..sort((a, b) => a.y.compareTo(b.y))).last.y * 1.2, 71 | ), 72 | duration: const Duration(milliseconds: 700), 73 | curve: Curves.easeOut, 74 | ); 75 | } 76 | } 77 | 78 | if (animate) { 79 | moveAC.addListener(moveACListener); 80 | return () => moveAC.removeListener(moveACListener); 81 | } else { 82 | final sub = timerStream(const Duration(seconds: 1)).listen((_) => moveACListener()); 83 | return sub.cancel; 84 | } 85 | }, 86 | [animate, getNextPointY], 87 | ); 88 | 89 | useValuesChanged( 90 | [animate], 91 | firstTime: true, 92 | callback: () { 93 | if (animate) { 94 | moveAC.forward(from: 0); 95 | } 96 | }, 97 | ); 98 | 99 | useValuesChanged( 100 | [], 101 | firstTime: true, 102 | callback: () { 103 | points.value = List.generate( 104 | pointsNum + 4, 105 | (i) => FlSpot( 106 | i * pointSpace - pointSpace, 107 | getInitialPointsY(i), 108 | ), 109 | ); 110 | 111 | maxYAC.value = max( 112 | 1, 113 | ([...points.value]..sort((a, b) => a.y.compareTo(b.y))).last.y * 1.2, 114 | ); 115 | return; 116 | }, 117 | ); 118 | 119 | return KeyboardListener( 120 | focusNode: FocusNode(), 121 | onKeyEvent: (event) { 122 | if (event.logicalKey == LogicalKeyboardKey.arrowUp) { 123 | mod.value *= 1.2; 124 | } 125 | if (event.logicalKey == LogicalKeyboardKey.arrowDown) { 126 | mod.value /= 1.2; 127 | } 128 | }, 129 | child: AnimatedBuilder( 130 | animation: maxYAC, 131 | builder: (context, child) { 132 | final barData = LineChartBarData( 133 | color: tint, 134 | dotData: FlDotData( 135 | show: false, 136 | ), 137 | spots: points.value, 138 | isCurved: true, 139 | barWidth: 1, 140 | isStrokeCapRound: true, 141 | ); 142 | final data = LineChartData( 143 | clipData: FlClipData.none(), 144 | gridData: FlGridData( 145 | show: false, 146 | ), 147 | titlesData: FlTitlesData( 148 | show: false, 149 | ), 150 | borderData: FlBorderData( 151 | show: false, 152 | ), 153 | minX: 0, 154 | maxX: 1, 155 | minY: -0.05, 156 | maxY: maxYAC.value, 157 | lineBarsData: [barData], 158 | ); 159 | 160 | return LayoutBuilder( 161 | builder: (context, constraints) { 162 | final graph = CustomPaint( 163 | painter: _PathPainter( 164 | path: LineChartPainter().generateNormalBarPath( 165 | Size(constraints.maxWidth, constraints.maxHeight), 166 | barData, 167 | barData.spots, 168 | PaintHolder(data, data, TextScaler.noScaling), 169 | ), 170 | color: tint, 171 | glow: false, 172 | ), 173 | ); 174 | final graphGlow = CustomPaint( 175 | painter: _PathPainter( 176 | path: LineChartPainter().generateNormalBarPath( 177 | Size(constraints.maxWidth, constraints.maxHeight), 178 | barData, 179 | barData.spots, 180 | PaintHolder(data, data, TextScaler.noScaling), 181 | ), 182 | color: tint, 183 | glow: true, 184 | ), 185 | ); 186 | return AnimatedBuilder( 187 | animation: moveAC, 188 | child: graph, 189 | builder: (context, graph) { 190 | const ballSize = 12.0; 191 | const ballOffset = 0; 192 | final (y, t) = getYFromX( 193 | constraints.maxWidth - ballOffset + moveAC.value * constraints.maxWidth / pointsNum, 194 | lastT.value, 195 | Size(constraints.maxWidth, constraints.maxHeight), 196 | barData, 197 | barData.spots.skip(barData.spots.length - 4).toList(), 198 | PaintHolder(data, data, TextScaler.noScaling), 199 | ); 200 | return CustomPaint( 201 | painter: _GradientPainter( 202 | strokeWidth: 1, 203 | radius: 10, 204 | gradient: LinearGradient( 205 | end: Alignment( 206 | 1, 207 | y / constraints.maxWidth * 2 * 2 - 1, 208 | ), 209 | stops: const [ 210 | 0.0, 211 | 0.9, 212 | 1.0, 213 | ], 214 | colors: [ 215 | tint.withOpacity(0.1), 216 | tint.withOpacity(0.3), 217 | tint.withOpacity(0.7), 218 | ], 219 | ), 220 | ), 221 | child: LayoutBuilder( 222 | builder: (context, constraints) { 223 | lastT.value = t; 224 | return Stack( 225 | children: [ 226 | Positioned( 227 | // left: constraints.maxWidth/2 - 228 | // ballOffset - 229 | // ballSize / 2, 230 | // top: y - ballSize / 2, 231 | width: constraints.maxWidth, 232 | height: constraints.maxHeight, 233 | child: ClipRRect( 234 | borderRadius: BorderRadius.circular(10), 235 | child: Stack( 236 | children: [ 237 | Positioned( 238 | left: constraints.maxWidth - ballOffset - ballSize / 2, 239 | top: y - ballSize / 2, 240 | child: SizedBox( 241 | width: ballSize, 242 | height: ballSize, 243 | child: Center( 244 | child: DecoratedBox( 245 | decoration: BoxDecoration( 246 | shape: BoxShape.circle, 247 | boxShadow: [ 248 | BoxShadow( 249 | color: tint, 250 | blurRadius: 60, 251 | spreadRadius: 30, 252 | ), 253 | ], 254 | ), 255 | ), 256 | ), 257 | ), 258 | ), 259 | ], 260 | ), 261 | ), 262 | ), 263 | Positioned( 264 | left: -moveAC.value * (constraints.maxWidth / pointsNum), 265 | width: constraints.maxWidth, 266 | height: constraints.maxHeight, 267 | child: ClipRRect( 268 | clipper: RRectCustomClipper( 269 | (size) => RRect.fromRectAndRadius( 270 | Rect.fromLTRB( 271 | moveAC.value * (constraints.maxWidth / pointsNum), 272 | 0, 273 | size.width + moveAC.value * (constraints.maxWidth / pointsNum) + 1000, 274 | size.height, 275 | ), 276 | const Radius.circular(10), 277 | ), 278 | ), 279 | child: graphGlow, 280 | ), 281 | ), 282 | Container( 283 | clipBehavior: Clip.hardEdge, 284 | decoration: const BoxDecoration(), 285 | width: constraints.maxWidth - ballOffset, 286 | child: Stack( 287 | children: [ 288 | Positioned( 289 | left: -moveAC.value * (constraints.maxWidth / pointsNum), 290 | width: constraints.maxWidth, 291 | height: constraints.maxHeight, 292 | child: graph!, 293 | ), 294 | ], 295 | ), 296 | ), 297 | Positioned( 298 | left: constraints.maxWidth - ballOffset - ballSize / 2, 299 | top: y - ballSize / 2, 300 | child: ClipRect( 301 | clipper: RectCustomClipper( 302 | (size) => Rect.fromLTRB( 303 | 0, 304 | 0, 305 | size.width, 306 | size.height, 307 | ), 308 | ), 309 | child: Container( 310 | decoration: BoxDecoration( 311 | shape: BoxShape.circle, 312 | color: Colors.white, 313 | border: Border.all( 314 | color: tint, 315 | width: 1.5, 316 | ), 317 | ), 318 | width: ballSize, 319 | height: ballSize, 320 | ), 321 | ), 322 | ), 323 | ], 324 | ); 325 | }, 326 | ), 327 | ); 328 | }, 329 | ); 330 | }, 331 | ); 332 | }, 333 | ), 334 | ); 335 | } 336 | } 337 | 338 | double _convertRadiusToSigma(double radius) { 339 | return radius * 0.57735 + 0.5; 340 | } 341 | 342 | class _PathPainter extends CustomPainter { 343 | _PathPainter({ 344 | required this.path, 345 | required this.glow, 346 | required this.color, 347 | }); 348 | 349 | final Paint _paint = Paint(); 350 | final Path path; 351 | final bool glow; 352 | final Color color; 353 | 354 | @override 355 | void paint(Canvas canvas, Size size) { 356 | _paint 357 | ..style = PaintingStyle.stroke 358 | ..strokeWidth = glow ? 30 : 1 359 | ..color = glow ? color.withOpacity(0.7) : color 360 | ..maskFilter = glow 361 | ? MaskFilter.blur( 362 | BlurStyle.normal, 363 | _convertRadiusToSigma(90), 364 | ) 365 | : null; 366 | if (glow) { 367 | canvas 368 | ..saveLayer(Rect.largest, Paint()) 369 | ..drawPath(path, _paint); 370 | final closedPath = path.shift(Offset.zero) 371 | ..relativeLineTo(0, -size.height) 372 | ..relativeLineTo(-size.width * 2, 0) 373 | ..close(); 374 | canvas 375 | ..drawPath( 376 | closedPath, 377 | Paint() 378 | ..style = PaintingStyle.fill 379 | ..blendMode = BlendMode.clear, 380 | ) 381 | ..restore(); 382 | } else { 383 | canvas.drawPath(path, _paint); 384 | } 385 | } 386 | 387 | @override 388 | bool shouldRepaint(CustomPainter oldDelegate) => oldDelegate != this; 389 | } 390 | 391 | class _GradientPainter extends CustomPainter { 392 | _GradientPainter({ 393 | required this.strokeWidth, 394 | required this.radius, 395 | required this.gradient, 396 | }); 397 | 398 | final Paint _paint = Paint(); 399 | final double radius; 400 | final double strokeWidth; 401 | final Gradient gradient; 402 | 403 | @override 404 | void paint(Canvas canvas, Size size) { 405 | // create outer rectangle equals size 406 | final outerRect = Offset.zero & size; 407 | final outerRRect = RRect.fromRectAndRadius(outerRect, Radius.circular(radius)); 408 | 409 | // create inner rectangle smaller by strokeWidth 410 | final innerRect = Rect.fromLTWH( 411 | strokeWidth, 412 | strokeWidth, 413 | size.width - strokeWidth * 2, 414 | size.height - strokeWidth * 2, 415 | ); 416 | final innerRRect = RRect.fromRectAndRadius( 417 | innerRect, 418 | Radius.circular(radius - strokeWidth), 419 | ); 420 | 421 | // apply gradient shader 422 | _paint.shader = gradient.createShader(outerRect); 423 | 424 | // create difference between outer and inner paths and draw it 425 | final path1 = Path()..addRRect(outerRRect); 426 | final path2 = Path()..addRRect(innerRRect); 427 | final path = Path.combine(PathOperation.difference, path1, path2); 428 | canvas.drawPath(path, _paint); 429 | } 430 | 431 | @override 432 | bool shouldRepaint(CustomPainter oldDelegate) => oldDelegate != this; 433 | } 434 | -------------------------------------------------------------------------------- /lib/widgets/torrent/torrent_overview/overview_files.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flarrent/models/torrent.dart'; 4 | import 'package:flarrent/state/torrents.dart'; 5 | import 'package:flarrent/utils/equal.dart'; 6 | import 'package:flarrent/utils/generic_join.dart'; 7 | import 'package:flarrent/utils/multiselect_algo.dart'; 8 | import 'package:flarrent/utils/use_values_changed.dart'; 9 | import 'package:flarrent/widgets/common/button.dart'; 10 | import 'package:flarrent/widgets/common/side_popup.dart'; 11 | import 'package:flarrent/widgets/common/smooth_scrolling.dart'; 12 | import 'package:flarrent/widgets/torrent/torrent.dart'; 13 | import 'package:flutter/material.dart'; 14 | import 'package:flutter_hooks/flutter_hooks.dart'; 15 | import 'package:freezed_annotation/freezed_annotation.dart'; 16 | import 'package:hooks_riverpod/hooks_riverpod.dart'; 17 | import 'package:responsive_grid_list/responsive_grid_list.dart'; 18 | import 'package:url_launcher/url_launcher.dart'; 19 | 20 | class OverviewFiles extends HookConsumerWidget { 21 | const OverviewFiles({ 22 | super.key, 23 | required this.id, 24 | }); 25 | 26 | final int id; 27 | 28 | @override 29 | Widget build(BuildContext context, WidgetRef ref) { 30 | final path = useState([]); 31 | final selectedFilesPositions = useState([]); 32 | final hideRulerAC = useAnimationController( 33 | duration: const Duration(milliseconds: 200), 34 | initialValue: 1, 35 | ); 36 | 37 | final files = ref 38 | .watch( 39 | torrentsProvider.select( 40 | (a) => Equal( 41 | a.torrents.firstWhere((data) => data.id == id).files, 42 | const DeepCollectionEquality().equals, 43 | ), 44 | ), 45 | ) 46 | .value; 47 | final torrentState = ref.watch( 48 | torrentsProvider.select( 49 | (a) => a.torrents.firstWhere((data) => data.id == id).state, 50 | ), 51 | ); 52 | 53 | useValuesChanged( 54 | [id], 55 | callback: () { 56 | path.value = []; 57 | selectedFilesPositions.value = []; 58 | }, 59 | ); 60 | 61 | useValuesChanged( 62 | [path.value], 63 | callback: () { 64 | selectedFilesPositions.value = []; 65 | }, 66 | ); 67 | 68 | useEffect( 69 | () { 70 | void callback() { 71 | if (selectedFilesPositions.value.isEmpty) { 72 | hideRulerAC.animateTo(1, curve: Curves.easeOutExpo); 73 | } else { 74 | hideRulerAC.animateTo(0, curve: Curves.easeOutExpo); 75 | } 76 | } 77 | 78 | selectedFilesPositions.addListener(callback); 79 | return () => selectedFilesPositions.removeListener(callback); 80 | }, 81 | [], 82 | ); 83 | 84 | final displayPathsList = ['/', ...path.value]; 85 | final theme = Theme.of(context); 86 | 87 | final hierarchy = _convertToDirectoryHierarchy( 88 | files.map((e) => e.name).toList(), 89 | ); 90 | var currentDirectory = hierarchy; 91 | for (final dir in path.value) { 92 | currentDirectory = currentDirectory[dir] as Map; 93 | } 94 | 95 | final orderedFiles = currentDirectory; 96 | final filesWithPosition = orderedFiles.entries.toList().asMap().entries.toList(); 97 | 98 | void onNodePress(int position, VoidCallback selectionDefault) { 99 | selectedFilesPositions.value = multiselectAlgo( 100 | selectedIndexes: selectedFilesPositions.value, 101 | index: position, 102 | selectionDefault: selectionDefault, 103 | ); 104 | } 105 | 106 | final selectedFileIndexes = _getIndexesRecursively( 107 | Map.fromEntries( 108 | selectedFilesPositions.value.map((p) => filesWithPosition[p].value), 109 | ), 110 | ); 111 | 112 | return Column( 113 | children: [ 114 | Stack( 115 | children: [ 116 | AnimatedBuilder( 117 | animation: hideRulerAC, 118 | builder: (context, child) { 119 | return Opacity( 120 | opacity: min(1, (1 - hideRulerAC.value) * 10), 121 | child: Align( 122 | alignment: Alignment.topRight, 123 | child: SizedBox( 124 | width: 160, 125 | height: 40, 126 | child: Stack( 127 | children: [ 128 | Positioned( 129 | bottom: (hideRulerAC.value * 40).roundToDouble(), 130 | width: 160, 131 | height: 40, 132 | child: child!, 133 | ), 134 | ], 135 | ), 136 | ), 137 | ), 138 | ); 139 | }, 140 | child: Stack( 141 | children: [ 142 | Transform.flip( 143 | flipY: true, 144 | child: SidePopup( 145 | color: theme.colorScheme.onSecondary, 146 | smoothLength: 40, 147 | ), 148 | ), 149 | Positioned.fill( 150 | child: Material( 151 | color: Colors.transparent, 152 | child: Row( 153 | mainAxisAlignment: MainAxisAlignment.end, 154 | children: [ 155 | Builder( 156 | builder: (context) { 157 | final selectedFiles = selectedFileIndexes.map((index) => files[index]).toList(); 158 | final differentPriorities = selectedFiles.isEmpty || 159 | selectedFiles.any((t) => t.priority != selectedFiles.first.priority); 160 | final currentPriority = 161 | differentPriorities ? TorrentPriority.normal : selectedFiles.first.priority; 162 | return Transform.flip( 163 | flipY: true, 164 | child: IconButton( 165 | icon: const Icon(Icons.low_priority), 166 | splashRadius: 15, 167 | // iconSize: 18, 168 | color: torrentPriorityToColor(currentPriority), 169 | onPressed: () { 170 | ref.read(torrentsProvider.notifier).changeFilePriority( 171 | id, 172 | selectedFileIndexes, 173 | TorrentPriority 174 | .values[(currentPriority.index + 1) % TorrentPriority.values.length], 175 | ); 176 | }, 177 | ), 178 | ); 179 | }, 180 | ), 181 | IconButton( 182 | icon: const Icon(Icons.pause), 183 | splashRadius: 15, 184 | // iconSize: 18, 185 | onPressed: () { 186 | ref.read(torrentsProvider.notifier).pauseFiles( 187 | id, 188 | selectedFileIndexes, 189 | ); 190 | }, 191 | ), 192 | IconButton( 193 | icon: const Icon(Icons.play_arrow_outlined), 194 | splashRadius: 15, 195 | // iconSize: 32, 196 | onPressed: () { 197 | ref.read(torrentsProvider.notifier).resumeFiles( 198 | id, 199 | selectedFileIndexes, 200 | ); 201 | }, 202 | ), 203 | const SizedBox( 204 | width: 10, 205 | ), 206 | ], 207 | ), 208 | ), 209 | ), 210 | ], 211 | ), 212 | ), 213 | Container( 214 | padding: const EdgeInsets.all(8).copyWith(left: 0), 215 | child: Row( 216 | children: displayPathsList 217 | .asMap() 218 | .entries 219 | .map( 220 | (e) { 221 | final dirName = e.value; 222 | const radius = 10.0; 223 | final radiuses = e.key == 0 224 | ? const BorderRadius.only( 225 | topLeft: Radius.circular(radius), 226 | bottomLeft: Radius.circular(radius), 227 | ) 228 | : (e.key == displayPathsList.length - 1 229 | ? const BorderRadius.only( 230 | topRight: Radius.circular(radius), 231 | bottomRight: Radius.circular(radius), 232 | ) 233 | : BorderRadius.zero); 234 | 235 | return Container( 236 | constraints: const BoxConstraints( 237 | maxWidth: 200, 238 | ), 239 | child: InkButton( 240 | padding: const EdgeInsets.all(6), 241 | color: theme.colorScheme.surface, 242 | borderRadius: radiuses, 243 | onPressed: () { 244 | path.value = path.value.sublist(0, e.key); 245 | }, 246 | child: Text( 247 | dirName, 248 | maxLines: 1, 249 | overflow: TextOverflow.ellipsis, 250 | ), 251 | ), 252 | ); 253 | }, 254 | ) 255 | .toList() 256 | .genericJoin(const SizedBox(width: 4)), 257 | ), 258 | ), 259 | ], 260 | ), 261 | Expanded( 262 | child: Material( 263 | color: Colors.transparent, 264 | child: Stack( 265 | children: [ 266 | Container( 267 | padding: const EdgeInsets.only(right: 8), 268 | width: double.infinity, 269 | child: Consumer( 270 | builder: (context, ref, child) { 271 | return SmoothScrolling( 272 | multiplier: 1, 273 | builder: (context, scrollController, physics) { 274 | return ResponsiveGridList( 275 | verticalGridMargin: 0, 276 | horizontalGridMargin: 0, 277 | horizontalGridSpacing: 4, 278 | verticalGridSpacing: 4, 279 | minItemWidth: 230, 280 | listViewBuilderOptions: ListViewBuilderOptions( 281 | controller: scrollController, 282 | physics: physics, 283 | ), 284 | children: filesWithPosition.map( 285 | (entry) { 286 | final position = entry.key; 287 | final e = entry.value; 288 | 289 | if (e.value != null) { 290 | // Directory 291 | final indexes = _getIndexesRecursively(e.value as Map); 292 | final inFiles = indexes.map((index) => files[index]).toList(); 293 | final inWantedFiles = inFiles.where((f) => f.state != TorrentState.paused).toList(); 294 | final name = e.key as String; 295 | 296 | final downloadedBytes = inWantedFiles.fold( 297 | 0, 298 | (previousValue, element) => previousValue + element.downloadedBytes, 299 | ); 300 | final sizeBytes = inWantedFiles.fold( 301 | 0, 302 | (previousValue, element) => previousValue + element.sizeBytes, 303 | ); 304 | return TorrentFileTile( 305 | titleColor: Colors.yellow, 306 | torrentState: torrentState, 307 | selected: selectedFilesPositions.value.contains(position), 308 | onPressed: () => onNodePress(position, () { 309 | path.value = [...path.value, name]; 310 | }), 311 | fileData: TorrentFileData( 312 | name: name, 313 | downloadedBytes: downloadedBytes, 314 | sizeBytes: sizeBytes, 315 | priority: TorrentPriority.normal, 316 | state: sizeBytes == downloadedBytes 317 | ? TorrentState.completed 318 | : inWantedFiles.any((f) => f.state == TorrentState.downloading) 319 | ? TorrentState.downloading 320 | : TorrentState.paused, 321 | ), 322 | ); 323 | // return InkButton( 324 | // key: ValueKey(name), 325 | // borderRadius: BorderRadius.circular(5), 326 | // onPressed: () => onNodePress(position, () { 327 | // path.value = [...path.value, name]; 328 | // }), 329 | // child: Container( 330 | // decoration: BoxDecoration( 331 | // borderRadius: BorderRadius.circular(5), 332 | // border: Border.all( 333 | // color: theme.colorScheme.onSecondary.withOpacity(0.2), 334 | // ), 335 | // color: selectedFilesPositions.value.contains(position) 336 | // ? Colors.blue.withOpacity(0.2) 337 | // : Colors.transparent, 338 | // ), 339 | // padding: const EdgeInsets.all(5), 340 | // width: double.infinity, 341 | // child: Text( 342 | // name, 343 | // style: TextStyle( 344 | // fontFamily: 'Roboto', 345 | // color: theme.colorScheme.onSecondary.withRed(255).withGreen(255), 346 | // fontSize: 12, 347 | // fontWeight: FontWeight.bold, 348 | // ), 349 | // ), 350 | // ), 351 | // ); 352 | } 353 | 354 | final index = e.key as int; 355 | final file = files[index]; 356 | 357 | return TorrentFileTile( 358 | key: ValueKey(index), 359 | torrentState: torrentState, 360 | fileData: file.copyWith( 361 | name: file.name.split('/').last, 362 | ), 363 | selected: selectedFilesPositions.value.contains(position), 364 | onPressed: () { 365 | selectedFilesPositions.value = multiselectAlgo( 366 | selectedIndexes: selectedFilesPositions.value, 367 | index: position, 368 | selectionDefault: () => onNodePress(position, () { 369 | final torrent = ref.read( 370 | torrentsProvider.select( 371 | (a) => a.torrents.firstWhere((data) => data.id == id), 372 | ), 373 | ); 374 | launchUrl(Uri.parse('file:${torrent.location}/${file.name}')); 375 | }), 376 | ); 377 | }, 378 | ); 379 | }, 380 | ).toList(), // The list of widgets in the list 381 | ); 382 | }, 383 | ); 384 | }, 385 | ), 386 | ), 387 | ], 388 | ), 389 | ), 390 | ), 391 | ], 392 | ); 393 | } 394 | } 395 | 396 | List _getIndexesRecursively(Map hierarchy) { 397 | final selectedIndexes = []; 398 | 399 | for (final node in hierarchy.entries) { 400 | // is file 401 | if (node.value == null) { 402 | selectedIndexes.add(node.key as int); 403 | } else { 404 | // is folder 405 | selectedIndexes.addAll(_getIndexesRecursively(node.value as Map)); 406 | } 407 | } 408 | 409 | return selectedIndexes; 410 | } 411 | 412 | Map _convertToDirectoryHierarchy(List filePaths) { 413 | final directoryHierarchy = {}; 414 | 415 | var i = 0; 416 | for (final filePath in filePaths) { 417 | final parts = filePath.split('/'); 418 | var currentDir = directoryHierarchy; 419 | 420 | for (var j = 0; j < parts.length - 1; j++) { 421 | final directoryName = parts[j]; 422 | currentDir.putIfAbsent(directoryName, () => {}); 423 | currentDir = currentDir[directoryName] as Map; 424 | } 425 | 426 | currentDir.putIfAbsent(i, () => null); 427 | i++; 428 | } 429 | 430 | return directoryHierarchy; 431 | } 432 | --------------------------------------------------------------------------------