diff --git a/lib/bloc/scoop_search_bloc.dart b/lib/bloc/scoop_search_bloc.dart new file mode 100644 index 0000000..0b37ab9 --- /dev/null +++ b/lib/bloc/scoop_search_bloc.dart @@ -0,0 +1,32 @@ +import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:equatable/equatable.dart'; + +import '../models/scoop_app_model.dart'; +import '../utils/scoop_utils.dart'; + +part 'scoop_search_event.dart'; +part 'scoop_search_state.dart'; + +class ScoopSearchBloc extends Bloc { + ScoopSearchBloc() : super(ScoopSearchInitial()) { + on( + (event, emit) async { + emit(ScoopSearchLoading()); + + try { + Map> data = {}; + if (event.query.isEmpty) { + data = await getAllInstallableApps(); + } else { + data = await searchInstallableApps(event.query); + } + emit(ScoopSearchLoaded(data)); + } catch (e) { + emit(ScoopSearchError(e.toString())); + } + }, + transformer: restartable(), + ); + } +} diff --git a/lib/bloc/scoop_search_event.dart b/lib/bloc/scoop_search_event.dart new file mode 100644 index 0000000..77709ed --- /dev/null +++ b/lib/bloc/scoop_search_event.dart @@ -0,0 +1,17 @@ +part of 'scoop_search_bloc.dart'; + +abstract class ScoopSearchEvent extends Equatable { + const ScoopSearchEvent(); + + @override + List get props => []; +} + +class ScoopSearchQueryChanged extends ScoopSearchEvent { + final String query; + + const ScoopSearchQueryChanged(this.query); + + @override + List get props => [query]; +} diff --git a/lib/bloc/scoop_search_state.dart b/lib/bloc/scoop_search_state.dart new file mode 100644 index 0000000..92922c6 --- /dev/null +++ b/lib/bloc/scoop_search_state.dart @@ -0,0 +1,30 @@ +part of 'scoop_search_bloc.dart'; + +abstract class ScoopSearchState extends Equatable { + const ScoopSearchState(); + + @override + List get props => []; +} + +class ScoopSearchInitial extends ScoopSearchState {} + +class ScoopSearchLoading extends ScoopSearchState {} + +class ScoopSearchLoaded extends ScoopSearchState { + final Map> apps; + + const ScoopSearchLoaded(this.apps); + + @override + List get props => [apps]; +} + +class ScoopSearchError extends ScoopSearchState { + final String message; + + const ScoopSearchError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/main.dart b/lib/main.dart index 2c9c292..ffc753f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,7 @@ import 'package:ladle/bloc/scoop_list_bloc.dart'; import 'package:system_theme/system_theme.dart'; import 'app.dart'; +import 'bloc/scoop_search_bloc.dart'; void main() async { final binding = @@ -29,6 +30,10 @@ void main() async { BlocProvider( create: (context) => ScoopListBloc()..add(ScoopLocate()), ), + BlocProvider( + create: (context) => + ScoopSearchBloc()..add(const ScoopSearchQueryChanged("")), + ), ], child: const LadleApp(), )); diff --git a/lib/pages/app_list_fragment.dart b/lib/pages/app_list_fragment.dart index ffd1c2b..8b6a6db 100644 --- a/lib/pages/app_list_fragment.dart +++ b/lib/pages/app_list_fragment.dart @@ -238,14 +238,18 @@ class _AppListFragmentState extends State { if (element.isEmpty) return; textController.text += "${DateFormat("hh:mm:ss").format(DateTime.now())} $element\n"; - textScroller.jumpTo(textScroller.position.maxScrollExtent); + if (textScroller.hasClients) { + textScroller.jumpTo(textScroller.position.maxScrollExtent); + } }); process.stderr.transform(utf8.decoder).forEach((element) { element = element.trim(); if (element.isEmpty) return; textController.text += "${DateFormat("hh:mm:ss").format(DateTime.now())} stderr: $element\n"; - textScroller.jumpTo(textScroller.position.maxScrollExtent); + if (textScroller.hasClients) { + textScroller.jumpTo(textScroller.position.maxScrollExtent); + } }); return FutureBuilder( future: process.exitCode, diff --git a/lib/pages/app_search_fragment.dart b/lib/pages/app_search_fragment.dart new file mode 100644 index 0000000..8bb0ca1 --- /dev/null +++ b/lib/pages/app_search_fragment.dart @@ -0,0 +1,394 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:ladle/bloc/scoop_search_bloc.dart'; +import 'package:ladle/models/scoop_app_model.dart'; +import 'package:ladle/utils/scoop_utils.dart'; +import 'package:ladle/utils/set_extension.dart'; +import 'package:styled_widget/styled_widget.dart'; + +import '../bloc/scoop_list_bloc.dart'; + +class AppSearchFragment extends StatefulWidget { + const AppSearchFragment({Key? key}) : super(key: key); + + @override + State createState() => _AppSearchFragmentState(); +} + +class _AppSearchFragmentState extends State { + final scrollController = ScrollController(); + final searchController = TextEditingController(); + final openedApps = {}; + int currentIndex = 0; + + String _previousSearch = ''; + + @override + void initState() { + super.initState(); + searchController.text = _previousSearch; + searchController.addListener(() { + if (_previousSearch != searchController.text) { + _previousSearch = searchController.text; + context + .read() + .add(ScoopSearchQueryChanged(searchController.text)); + } + }); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + Widget body; + if (state is ScoopSearchLoading) { + body = _buildLoadingBody(); + } else if (state is ScoopSearchLoaded) { + body = _buildLoadedBody(state); + } else if (state is ScoopSearchError) { + body = _buildErrorBody(state); + } else { + body = _buildInitialBody(); + } + return ScaffoldPage( + padding: EdgeInsets.zero, + header: TextBox( + controller: searchController, + placeholder: "Search for apps", + ), + content: body, + ); + }, + ); + } + + Widget _buildLoadingBody() { + return Flex( + direction: Axis.horizontal, + children: [ + Expanded( + flex: 1, + child: const Center( + child: ProgressBar(), + ).backgroundColor(Colors.black.withAlpha(50)), + ), + const Expanded( + flex: 3, + child: Center( + child: ProgressBar(), + ), + ), + ], + ); + } + + Widget _buildLoadedBody(ScoopSearchLoaded state) { + return Flex( + direction: Axis.horizontal, + children: [ + Expanded( + flex: 1, + child: ListView.builder( + key: PageStorageKey(state.apps.length), + controller: scrollController, + itemCount: state.apps["main"]?.length ?? 0, + itemBuilder: (context, index) => + _createAppWidget(state.apps["main"]![index]), + ).backgroundColor(Colors.black.withAlpha(50)), + ), + Expanded( + flex: 3, + child: TabView( + wheelScroll: true, + currentIndex: currentIndex, + onChanged: (index) => setState(() => currentIndex = index), + tabs: openedApps + .map((e) => Tab( + text: Text(e.name), + closeIcon: FluentIcons.chrome_close, + onClosed: () => setState( + () { + final appIdx = openedApps.indexOf(e); + if (appIdx < currentIndex) { + currentIndex--; + } else { + currentIndex = 0; + } + openedApps.remove(e); + }, + ), + )) + .toList(growable: false), + bodies: openedApps.map(_createAppPage).toList(growable: false), + ), + ), + ], + ); + } + + Widget _buildErrorBody(ScoopSearchError state) { + return Text(state.message).center(); + } + + Widget _buildInitialBody() { + return const Text("Search for apps using input above").center(); + } + + Widget _createAppWidget(ScoopAppModel appModel) { + return GestureDetector( + onTap: () => setState(() { + if (openedApps.contains(appModel)) { + currentIndex = openedApps.indexOf(appModel); + } else { + openedApps.add(appModel); + currentIndex = openedApps.length - 1; + } + }), + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + appModel.name, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + DateFormat("dd.MM.yyyy hh:mm") + .format(appModel.updatedAt), + style: const TextStyle( + fontSize: 10, + ), + ), + ], + ), + Text( + appModel.description, + style: const TextStyle( + fontSize: 12, + ), + overflow: TextOverflow.ellipsis, + ), + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + FluentIcons.repo_solid, + size: 12, + ), + const SizedBox(width: 5), + Text( + appModel.bucket, + style: const TextStyle( + fontSize: 12, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _createAppPage(ScoopAppModel appModel) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(appModel.name).fontSize(32).bold().paddingDirectional(bottom: 16), + Row( + children: [ + Chip( + image: const Icon(FluentIcons.repo_solid), + text: Text(appModel.bucket), + ).padding(right: 8), + Chip.selected( + image: const Icon(FluentIcons.calendar), + text: Text( + DateFormat("dd.MM.yyyy hh:mm").format(appModel.updatedAt)), + ).padding(right: 8), + FutureBuilder( + future: checkAppInstalled(appModel), + builder: (context, snapshot) { + if (snapshot.hasData) { + final data = snapshot.data; + if (data != null && data) { + return Row(children: _buttonsForInstalledApp(appModel)); + } else { + return Row(children: _buttonsForNotInstalledApp(appModel)); + } + } else if (snapshot.hasError) { + return Chip( + image: const Icon(FluentIcons.error), + text: Text(snapshot.error.toString()), + ).padding(right: 8); + } else { + return const SizedBox(); + } + }, + ), + ], + ).paddingDirectional(bottom: 16), + Text( + appModel.description, + style: FluentTheme.of(context).typography.body, + ), + ], + ).padding(all: 16); + } + + List _buttonsForInstalledApp(ScoopAppModel appModel) { + return [ + OutlinedButton( + child: const Text("Update"), + onPressed: () => _runScoopRoutine( + title: "Updating ${appModel.name}", + params: ["update", appModel.name], + ), + ).padding(right: 8), + OutlinedButton( + child: const Text("Reinstall"), + onPressed: () => _runScoopRoutine( + title: "Reinstalling ${appModel.name}", + executable: "powershell", + params: [ + "-c", + "scoop", + "uninstall", + appModel.name, + "&&", + "scoop", + "install", + appModel.name + ], + ).then((_) { + context + .read() + .add(ScoopSearchQueryChanged(searchController.text)); + }), + ).padding(right: 8), + OutlinedButton( + child: const Text("Uninstall"), + onPressed: () => _runScoopRoutine( + title: "Uninstalling ${appModel.name}", + params: ["uninstall", appModel.name], + ).then((_) { + context + .read() + .add(ScoopSearchQueryChanged(searchController.text)); + }), + ), + ]; + } + + List _buttonsForNotInstalledApp(ScoopAppModel appModel) { + return [ + OutlinedButton( + child: const Text("Install"), + onPressed: () => _runScoopRoutine( + title: "Installing ${appModel.name}", + executable: "scoop", + params: ["install", appModel.name], + ).then((_) { + context + .read() + .add(ScoopSearchQueryChanged(searchController.text)); + }), + ).padding(right: 8), + ]; + } + + Future _runScoopRoutine({ + String executable = "scoop", + required String title, + required List params, + }) async { + final textController = TextEditingController(); + final textScroller = ScrollController(); + final process = await Process.start(executable, params, runInShell: true); + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + textController.text = "Waiting for process to start...\n"; + process.stdout.transform(utf8.decoder).forEach((element) { + element = element.trim(); + if (element.isEmpty) return; + textController.text += + "${DateFormat("hh:mm:ss").format(DateTime.now())} $element\n"; + if (textScroller.hasClients) { + textScroller.jumpTo(textScroller.position.maxScrollExtent); + } + }); + process.stderr.transform(utf8.decoder).forEach((element) { + element = element.trim(); + if (element.isEmpty) return; + textController.text += + "${DateFormat("hh:mm:ss").format(DateTime.now())} stderr: $element\n"; + if (textScroller.hasClients) { + textScroller.jumpTo(textScroller.position.maxScrollExtent); + } + }); + return FutureBuilder( + future: process.exitCode, + builder: (context, snapshot) { + return ContentDialog( + constraints: const BoxConstraints(minWidth: 1000), + title: Text(title), + content: TextBox( + enabled: false, + maxLines: 5, + minLines: 5, + controller: textController, + scrollController: textScroller, + scrollPhysics: snapshot.hasData + ? null + : const NeverScrollableScrollPhysics(), + ), + actions: snapshot.hasData + ? [ + Button( + child: const Text("Close"), + onPressed: () => Navigator.of(context).pop(), + ), + ] + : [ + Button( + child: const Text("Cancel"), + onPressed: () { + process.kill(); + Navigator.pop(context); + }, + ) + ], + ); + }, + ); + }, + ); + process.kill(); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index a3e041f..4b6bbdc 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -7,6 +7,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../bloc/scoop_list_bloc.dart'; import '../widgets/windows_buttons_widget.dart'; import 'app_list_fragment.dart'; +import 'app_search_fragment.dart'; class HomePage extends StatefulWidget { const HomePage({Key? key}) : super(key: key); @@ -161,7 +162,7 @@ class _HomePageState extends State { index: _fragmentIndex, children: const [ AppListFragment(), - Center(child: Text("Updates")), + AppSearchFragment(), ], ); } diff --git a/lib/utils/scoop_utils.dart b/lib/utils/scoop_utils.dart index 84a065a..ab4cb29 100644 --- a/lib/utils/scoop_utils.dart +++ b/lib/utils/scoop_utils.dart @@ -26,12 +26,56 @@ Future> getScoopBuckets() async { return buckets; } -Future> searchScoopApps(String query) async { +Future>> searchInstallableApps( + String query) async { final home = Platform.environment["UserProfile"]; final buckets = await getScoopBuckets(); - final apps = []; + + Map> bucketMap = {}; for (final bucket in buckets) { + final apps = []; + final bucketDir = Directory("$home/scoop/buckets/$bucket/bucket"); + final files = bucketDir.listSync().whereType().where( + (element) => + element.path.endsWith(".json") || + element.path.endsWith(".yml") || + element.path.endsWith(".yaml"), + ); + for (final file in files) { + final content = await file.readAsString(); + final data = + file.path.endsWith(".json") ? jsonDecode(content) : loadYaml(content); + + String appName = + file.path.split("\\").last.replaceAll(RegExp(r"\.(json|ya?ml)"), ""); + String appDescription = data["description"] ?? "No description"; + DateTime appUpdatedAt = await file.lastModified(); + + if (appName.toLowerCase().contains(query.toLowerCase()) || + appDescription.toLowerCase().contains(query.toLowerCase())) { + apps.add(ScoopAppModel( + name: appName, + description: appDescription, + bucket: bucket, + updatedAt: appUpdatedAt, + )); + } + } + bucketMap[bucket] = apps; + } + + return bucketMap; +} + +Future>> getAllInstallableApps() async { + final home = Platform.environment["UserProfile"]; + final buckets = await getScoopBuckets(); + + Map> bucketMap = {}; + + for (final bucket in buckets) { + final apps = []; final bucketDir = Directory("$home/scoop/buckets/$bucket/bucket"); final files = bucketDir.listSync().whereType().where( (element) => @@ -56,9 +100,10 @@ Future> searchScoopApps(String query) async { updatedAt: appUpdatedAt, )); } + bucketMap[bucket] = apps; } - return apps; + return bucketMap; } Future> getInstalledScoopApps() async { @@ -98,3 +143,11 @@ Future> getInstalledScoopApps() async { return apps; } + +Future checkAppInstalled(ScoopAppModel app) async { + final home = Platform.environment["UserProfile"]; + final scoopAppsDir = Directory("$home/scoop/apps"); + final appDir = Directory("${scoopAppsDir.path}/${app.name}"); + + return await appDir.exists(); +}