add search fragment

This commit is contained in:
Andrew 2022-09-13 11:13:17 +07:00
parent 72a46116b3
commit 4833b5c18c
8 changed files with 542 additions and 6 deletions

View file

@ -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<ScoopSearchEvent, ScoopSearchState> {
ScoopSearchBloc() : super(ScoopSearchInitial()) {
on<ScoopSearchQueryChanged>(
(event, emit) async {
emit(ScoopSearchLoading());
try {
Map<String, List<ScoopAppModel>> 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(),
);
}
}

View file

@ -0,0 +1,17 @@
part of 'scoop_search_bloc.dart';
abstract class ScoopSearchEvent extends Equatable {
const ScoopSearchEvent();
@override
List<Object> get props => [];
}
class ScoopSearchQueryChanged extends ScoopSearchEvent {
final String query;
const ScoopSearchQueryChanged(this.query);
@override
List<Object> get props => [query];
}

View file

@ -0,0 +1,30 @@
part of 'scoop_search_bloc.dart';
abstract class ScoopSearchState extends Equatable {
const ScoopSearchState();
@override
List<Object> get props => [];
}
class ScoopSearchInitial extends ScoopSearchState {}
class ScoopSearchLoading extends ScoopSearchState {}
class ScoopSearchLoaded extends ScoopSearchState {
final Map<String, List<ScoopAppModel>> apps;
const ScoopSearchLoaded(this.apps);
@override
List<Object> get props => [apps];
}
class ScoopSearchError extends ScoopSearchState {
final String message;
const ScoopSearchError(this.message);
@override
List<Object> get props => [message];
}

View file

@ -6,6 +6,7 @@ import 'package:ladle/bloc/scoop_list_bloc.dart';
import 'package:system_theme/system_theme.dart'; import 'package:system_theme/system_theme.dart';
import 'app.dart'; import 'app.dart';
import 'bloc/scoop_search_bloc.dart';
void main() async { void main() async {
final binding = final binding =
@ -29,6 +30,10 @@ void main() async {
BlocProvider( BlocProvider(
create: (context) => ScoopListBloc()..add(ScoopLocate()), create: (context) => ScoopListBloc()..add(ScoopLocate()),
), ),
BlocProvider(
create: (context) =>
ScoopSearchBloc()..add(const ScoopSearchQueryChanged("")),
),
], ],
child: const LadleApp(), child: const LadleApp(),
)); ));

View file

@ -238,14 +238,18 @@ class _AppListFragmentState extends State<AppListFragment> {
if (element.isEmpty) return; if (element.isEmpty) return;
textController.text += textController.text +=
"${DateFormat("hh:mm:ss").format(DateTime.now())} $element\n"; "${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) { process.stderr.transform(utf8.decoder).forEach((element) {
element = element.trim(); element = element.trim();
if (element.isEmpty) return; if (element.isEmpty) return;
textController.text += textController.text +=
"${DateFormat("hh:mm:ss").format(DateTime.now())} stderr: $element\n"; "${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<int>( return FutureBuilder<int>(
future: process.exitCode, future: process.exitCode,

View file

@ -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<AppSearchFragment> createState() => _AppSearchFragmentState();
}
class _AppSearchFragmentState extends State<AppSearchFragment> {
final scrollController = ScrollController();
final searchController = TextEditingController();
final openedApps = <ScoopAppModel>{};
int currentIndex = 0;
String _previousSearch = '';
@override
void initState() {
super.initState();
searchController.text = _previousSearch;
searchController.addListener(() {
if (_previousSearch != searchController.text) {
_previousSearch = searchController.text;
context
.read<ScoopSearchBloc>()
.add(ScoopSearchQueryChanged(searchController.text));
}
});
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ScoopSearchBloc, ScoopSearchState>(
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<bool>(
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<Widget> _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<ScoopSearchBloc>()
.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<ScoopSearchBloc>()
.add(ScoopSearchQueryChanged(searchController.text));
}),
),
];
}
List<Widget> _buttonsForNotInstalledApp(ScoopAppModel appModel) {
return [
OutlinedButton(
child: const Text("Install"),
onPressed: () => _runScoopRoutine(
title: "Installing ${appModel.name}",
executable: "scoop",
params: ["install", appModel.name],
).then((_) {
context
.read<ScoopSearchBloc>()
.add(ScoopSearchQueryChanged(searchController.text));
}),
).padding(right: 8),
];
}
Future<void> _runScoopRoutine({
String executable = "scoop",
required String title,
required List<String> 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<int>(
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();
}
}

View file

@ -7,6 +7,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../bloc/scoop_list_bloc.dart'; import '../bloc/scoop_list_bloc.dart';
import '../widgets/windows_buttons_widget.dart'; import '../widgets/windows_buttons_widget.dart';
import 'app_list_fragment.dart'; import 'app_list_fragment.dart';
import 'app_search_fragment.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key); const HomePage({Key? key}) : super(key: key);
@ -161,7 +162,7 @@ class _HomePageState extends State<HomePage> {
index: _fragmentIndex, index: _fragmentIndex,
children: const [ children: const [
AppListFragment(), AppListFragment(),
Center(child: Text("Updates")), AppSearchFragment(),
], ],
); );
} }

View file

@ -26,12 +26,56 @@ Future<List<String>> getScoopBuckets() async {
return buckets; return buckets;
} }
Future<List<ScoopAppModel>> searchScoopApps(String query) async { Future<Map<String, List<ScoopAppModel>>> searchInstallableApps(
String query) async {
final home = Platform.environment["UserProfile"]; final home = Platform.environment["UserProfile"];
final buckets = await getScoopBuckets(); final buckets = await getScoopBuckets();
final apps = <ScoopAppModel>[];
Map<String, List<ScoopAppModel>> bucketMap = {};
for (final bucket in buckets) { for (final bucket in buckets) {
final apps = <ScoopAppModel>[];
final bucketDir = Directory("$home/scoop/buckets/$bucket/bucket");
final files = bucketDir.listSync().whereType<File>().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<Map<String, List<ScoopAppModel>>> getAllInstallableApps() async {
final home = Platform.environment["UserProfile"];
final buckets = await getScoopBuckets();
Map<String, List<ScoopAppModel>> bucketMap = {};
for (final bucket in buckets) {
final apps = <ScoopAppModel>[];
final bucketDir = Directory("$home/scoop/buckets/$bucket/bucket"); final bucketDir = Directory("$home/scoop/buckets/$bucket/bucket");
final files = bucketDir.listSync().whereType<File>().where( final files = bucketDir.listSync().whereType<File>().where(
(element) => (element) =>
@ -56,9 +100,10 @@ Future<List<ScoopAppModel>> searchScoopApps(String query) async {
updatedAt: appUpdatedAt, updatedAt: appUpdatedAt,
)); ));
} }
bucketMap[bucket] = apps;
} }
return apps; return bucketMap;
} }
Future<List<ScoopAppModel>> getInstalledScoopApps() async { Future<List<ScoopAppModel>> getInstalledScoopApps() async {
@ -98,3 +143,11 @@ Future<List<ScoopAppModel>> getInstalledScoopApps() async {
return apps; return apps;
} }
Future<bool> 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();
}