diff --git a/CHANGELOG.md b/CHANGELOG.md index 76d0842..aa7de24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.4.0 - 2025-11-11 + +* support --noempty, --cache and --symlinks cli option +* added preferences screen + ## 0.3.2 - 2025-11-11 * print app version on startup diff --git a/lib/domain/fdupes_bloc.dart b/lib/domain/fdupes_bloc.dart index 2002b14..0c8e7a8 100644 --- a/lib/domain/fdupes_bloc.dart +++ b/lib/domain/fdupes_bloc.dart @@ -14,8 +14,12 @@ part 'fdupes_state.dart'; class FdupesBloc extends Bloc { final List? initialDirs; String? fdupesLocation; + final SharedPreferences sharedPreferences; - FdupesBloc({this.initialDirs}) : super(FdupesStateInitial(initialDirs)) { + FdupesBloc({ + this.initialDirs, + required this.sharedPreferences, + }) : super(FdupesStateInitial(initialDirs)) { on(_onCheckFdupesAvailability); on(_onSelectFdupesLocation); on(_onDirsSelected); @@ -62,8 +66,8 @@ class FdupesBloc extends Bloc { return; } add(FdupesEventCheckFdupesAvailability()); - } - else emit(FdupesStateFdupesNotFound(statusMsg: 'Not a valid fdupes binary.')); + } else + emit(FdupesStateFdupesNotFound(statusMsg: 'Not a valid fdupes binary.')); } Future validFdupesLocation(String path) async { @@ -84,7 +88,16 @@ class FdupesBloc extends Bloc { if (s is FdupesStateResult) { emit(s.copyWith(loading: true)); } - final dupes = await findDupes(event.dirs, emit: emit); + final skipEmpty = sharedPreferences.getBool('noempty') ?? false; + final useCache = sharedPreferences.getBool('usecache') ?? false; + final followSymlinks = sharedPreferences.getBool('followsymlinks') ?? false; + final dupes = await findDupes( + event.dirs, + emit: emit, + skipEmpty: skipEmpty, + useCache: useCache, + followSymlinks: followSymlinks, + ); emit(FdupesStateResult(dirs: event.dirs, dupeGroups: dupes)); } @@ -156,10 +169,24 @@ class FdupesBloc extends Bloc { } } - Future>> findDupes(List dirs, {required Emitter emit}) async { + Future>> findDupes( + List dirs, { + required Emitter emit, + required bool skipEmpty, + required bool useCache, + required bool followSymlinks, + }) async { print("finding dupes in dirs $dirs"); + final args = [ + '-r', + if (skipEmpty) '--noempty', + if (useCache) '--usecache', + if (followSymlinks) '--symlinks', + ...dirs.map((d) => d.path), + ]; + print('cmd line: $fdupesLocation $args'); + Process process = await Process.start(fdupesLocation!, args); List> dupes = []; - Process process = await Process.start(fdupesLocation!, ['-r', ...dirs.map((d) => d.path)]); // stdout.addStream(process.stdout); final regex = RegExp(r'\[(\d+)/(\d+)\]'); final stderrBC = process.stderr.asBroadcastStream(); diff --git a/lib/main.dart b/lib/main.dart index d3953e3..570c1d0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:fdupes_gui/theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class MyBlocObserver extends BlocObserver { @override @@ -49,18 +50,31 @@ Future main(List args) async { Bloc.observer = MyBlocObserver(); - runApp(MyApp(initialDirs)); + WidgetsFlutterBinding.ensureInitialized(); + final sharedPreferences = await SharedPreferences.getInstance(); + + runApp(MyApp( + initialDirs, + sharedPreferences: sharedPreferences, + )); } class MyApp extends StatelessWidget { final List? initialDirs; + final SharedPreferences sharedPreferences; - MyApp(this.initialDirs); + MyApp( + this.initialDirs, { + required this.sharedPreferences, + }); @override Widget build(BuildContext context) { return BlocProvider( - create: (context) => FdupesBloc(initialDirs: initialDirs), + create: (context) => FdupesBloc( + initialDirs: initialDirs, + sharedPreferences: sharedPreferences, + ), child: AdaptiveTheme( // debugShowFloatingThemeButton: true, light: FdupesTheme.light(), @@ -70,7 +84,11 @@ class MyApp extends StatelessWidget { title: 'Fdupes gui', theme: theme, darkTheme: darkTheme, - home: Material(child: DupeScreen()), + home: Material( + child: DupeScreen( + sharedPreferences: sharedPreferences, + ), + ), ), ), ); diff --git a/lib/presentation/about_dialog.dart b/lib/presentation/about_dialog.dart index c9fb914..7c9fb4e 100644 --- a/lib/presentation/about_dialog.dart +++ b/lib/presentation/about_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/link.dart'; -Future showAboutDialoog( +Future showAppAboutDialog( BuildContext context, ) async { final appInfo = await PackageInfo.fromPlatform(); diff --git a/lib/presentation/dupe_screen.dart b/lib/presentation/dupe_screen.dart index 72dd069..7f79343 100644 --- a/lib/presentation/dupe_screen.dart +++ b/lib/presentation/dupe_screen.dart @@ -2,22 +2,43 @@ import 'package:fdupes_gui/core/util.dart' as util; import 'package:fdupes_gui/domain/fdupes_bloc.dart'; import 'package:fdupes_gui/presentation/dupes_body.dart'; import 'package:fdupes_gui/presentation/dupes_top_bar.dart'; +import 'package:fdupes_gui/presentation/prefs_dialog.dart'; import 'package:fdupes_gui/presentation/select_folder_dialog.dart'; import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class DupeScreen extends StatelessWidget { + final SharedPreferences sharedPreferences; + + DupeScreen({super.key, required this.sharedPreferences}); + @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { if (state is FdupesStateInitial) { - return Center( - child: ElevatedButton( - child: Text('Select folder'), - onPressed: () => showSelectFolderDialog(context, initialDir: null, currentDirs: []), - ), + return Stack( + alignment: Alignment.center, + children: [ + ElevatedButton( + child: Text('Select folder'), + onPressed: () => showSelectFolderDialog(context, initialDir: null, currentDirs: []), + ), + Positioned( + right: 16, + top: 16, + child: Tooltip( + message: 'Preferences', + child: ElevatedButton( + child: Icon(Icons.settings), + style: ElevatedButton.styleFrom(shape: CircleBorder()), + onPressed: () => showPreferencesDialog(context, sharedPreferences), + ), + ), + ), + ], ); } if (state is FdupesStateFdupesNotFound) { @@ -72,7 +93,10 @@ class DupeScreen extends StatelessWidget { padding: EdgeInsets.all(8), child: Column( children: [ - DupesTopBar(baseDirs: state.dirs), + DupesTopBar( + baseDirs: state.dirs, + sharedPreferences: sharedPreferences, + ), SizedBox(height: 8), if (state.dupeGroups.isEmpty) Text('no dupes found') diff --git a/lib/presentation/dupes_top_bar.dart b/lib/presentation/dupes_top_bar.dart index efa589d..a284190 100644 --- a/lib/presentation/dupes_top_bar.dart +++ b/lib/presentation/dupes_top_bar.dart @@ -3,16 +3,20 @@ import 'dart:io'; import 'package:fdupes_gui/domain/fdupes_bloc.dart'; import 'package:fdupes_gui/presentation/about_dialog.dart'; import 'package:fdupes_gui/presentation/base_dirs.dart'; +import 'package:fdupes_gui/presentation/prefs_dialog.dart'; import 'package:fdupes_gui/presentation/select_folder_dialog.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class DupesTopBar extends StatelessWidget { final List baseDirs; + final SharedPreferences sharedPreferences; DupesTopBar({ super.key, required this.baseDirs, + required this.sharedPreferences, }); @override @@ -44,12 +48,24 @@ class DupesTopBar extends StatelessWidget { Expanded( child: BaseDirs(baseDirs: baseDirs), ), - Tooltip( - message: 'Find duplicates', - child: ElevatedButton( - child: Icon(Icons.info_outline), - onPressed: () => showAboutDialoog(context), - ), + Column( + children: [ + Tooltip( + message: 'About this app', + child: ElevatedButton( + child: Icon(Icons.info_outline), + onPressed: () => showAppAboutDialog(context), + ), + ), + SizedBox(height: 8), + Tooltip( + message: 'Preferences', + child: ElevatedButton( + child: Icon(Icons.settings), + onPressed: () => showPreferencesDialog(context, sharedPreferences), + ), + ), + ], ), ], ); diff --git a/lib/presentation/prefs_dialog.dart b/lib/presentation/prefs_dialog.dart new file mode 100644 index 0000000..a847d37 --- /dev/null +++ b/lib/presentation/prefs_dialog.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future showPreferencesDialog( + BuildContext context, + SharedPreferences sharedPreferences, +) async { + showDialog( + context: context, + barrierDismissible: true, + builder: (context) => PreferencesDialog(sharedPreferences), + ); +} + +class PreferencesDialog extends StatefulWidget { + final SharedPreferences sharedPreferences; + + PreferencesDialog(this.sharedPreferences); + + @override + State createState() => _PreferencesDialogState(); +} + +class _PreferencesDialogState extends State { + /// use local state instead of directly accessing shared preferences since we write the settings asynchronously without waiting + late bool skipEmpty; + late bool useCache; + late bool followSymlinks; + + @override + void initState() { + super.initState(); + skipEmpty = widget.sharedPreferences.getBool('noempty') ?? false; + useCache = widget.sharedPreferences.getBool('usecache') ?? false; + followSymlinks = widget.sharedPreferences.getBool('followsymlinks') ?? false; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Preferences'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SwitchListTile( + title: const Text('Skip empty files', softWrap: false), + value: skipEmpty, + onChanged: (value) { + setState(() { + skipEmpty = value; + widget.sharedPreferences.setBool('noempty', value); + }); + }, + ), + SwitchListTile( + title: const Text('Use cache'), + subtitle: const Text('fdupes 2.3.0+', softWrap: false), + value: useCache, + onChanged: (value) { + setState(() { + useCache = value; + widget.sharedPreferences.setBool('usecache', value); + }); + }), + SwitchListTile( + title: const Text('Follow symlinks'), + value: followSymlinks, + onChanged: (value) { + setState(() { + followSymlinks = value; + widget.sharedPreferences.setBool('followsymlinks', value); + }); + }), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 97f31e2..3b3e672 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: archive - sha256: "7dcbd0f87fe5f61cb28da39a1a8b70dbc106e2fe0516f7836eb7bb2948481a12" + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" url: "https://pub.dev" source: hosted - version: "4.0.5" + version: "4.0.7" args: dependency: transitive description: @@ -41,30 +41,30 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.4" - characters: + boolean_selector: dependency: transitive description: - name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" url: "https://pub.dev" source: hosted - version: "1.3.0" - checked_yaml: + version: "2.1.1" + characters: dependency: transitive description: - name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" url: "https://pub.dev" source: hosted - version: "2.0.3" - cli_util: + version: "1.3.0" + clock: dependency: transitive description: - name: cli_util - sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf url: "https://pub.dev" source: hosted - version: "0.4.1" + version: "1.1.1" collection: dependency: transitive description: @@ -97,6 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.7" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" ffi: dependency: transitive description: @@ -190,15 +198,11 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.6" - flutter_launcher_icons: + flutter_test: dependency: "direct dev" - description: - path: "." - ref: multi_res_ico - resolved-ref: "3a8a02159cb6d0d8d97bf25628408fa30dbbf356" - url: "https://github.com/mx1up/flutter_launcher_icons.git" - source: git - version: "0.14.3" + description: flutter + source: sdk + version: "0.0.0" flutter_web_plugins: dependency: transitive description: flutter @@ -237,14 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.8" - json_annotation: + matcher: dependency: transitive description: - name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "0.12.16" material_color_utilities: dependency: transitive description: @@ -297,10 +301,10 @@ packages: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.8.3" path_provider_linux: dependency: transitive description: @@ -353,10 +357,10 @@ packages: dependency: transitive description: name: posix - sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.0.3" process_run: dependency: "direct main" description: @@ -369,10 +373,10 @@ packages: dependency: transitive description: name: provider - sha256: "489024f942069c2920c844ee18bb3d467c69e48955a4f32d1677f71be103e310" + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" url: "https://pub.dev" source: hosted - version: "6.1.4" + version: "6.1.5+1" pub_semver: dependency: transitive description: @@ -446,18 +450,34 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.11.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.2.0" synchronized: dependency: transitive description: @@ -470,10 +490,18 @@ packages: dependency: transitive description: name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" url: "https://pub.dev" source: hosted - version: "1.2.2" + version: "0.6.0" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5ef163f..a0352f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fdupes_gui description: fdupes front-end publish_to: 'none' -version: 0.3.2+8 +version: 0.4.0+9 dependency_overrides: # https://github.com/brendan-duncan/image/pull/732 @@ -30,12 +30,13 @@ dependencies: shared_preferences: ^2.2.2 dev_dependencies: -# flutter_test: -# sdk: flutter - flutter_launcher_icons: - git: - url: https://github.com/mx1up/flutter_launcher_icons.git - ref: multi_res_ico + flutter_test: + sdk: flutter + # disable for now due to conflicting `path` dep with flutter_test +# flutter_launcher_icons: +# git: +# url: https://github.com/mx1up/flutter_launcher_icons.git +# ref: multi_res_ico flutter: uses-material-design: true diff --git a/test/widget_test.dart b/test/widget_test.dart index e936983..784aa11 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -11,11 +11,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fdupes_gui/main.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { + final sharedPrefs = await SharedPreferences.getInstance(); // Build our app and trigger a frame. - await tester.pumpWidget(MyApp([Directory('')])); + await tester.pumpWidget(MyApp( + [Directory('')], + sharedPreferences: sharedPrefs, + )); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget);