/* * Copyright 2023 Hongen Wang All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:proxypin/network/http/content_type.dart'; import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/utils/lang.dart'; import 'model/search_model.dart'; /// @author wanghongen /// 2023/8/6 class SearchConditions extends StatefulWidget { final SearchModel searchModel; final Function(SearchModel searchModel)? onSearch; final EdgeInsetsGeometry? padding; const SearchConditions({super.key, required this.searchModel, this.onSearch, this.padding}); @override State createState() { return SearchConditionsState(); } } class SearchConditionsState extends State { final Map requestContentMap = { 'JSON': ContentType.json, 'FORM-URL': ContentType.formUrl, 'FORM-DATA': ContentType.formData, }; final Map responseContentMap = { 'JSON': ContentType.json, 'IMAGE': ContentType.image, 'HTML': ContentType.html, 'XML': ContentType.xml, 'JS': ContentType.js, 'CSS': ContentType.css, 'TEXT': ContentType.text, }; late SearchModel searchModel; AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { super.initState(); searchModel = widget.searchModel.clone(); } @override Widget build(BuildContext context) { requestContentMap[localizations.all] = null; responseContentMap[localizations.all] = null; Color primaryColor = ColorScheme.of(context).primary; return Container( padding: widget.padding, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // keyword TextFormField( initialValue: searchModel.keyword, onChanged: (val) => searchModel.keyword = val, onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), decoration: InputDecoration( isCollapsed: true, contentPadding: const EdgeInsets.all(10), border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(15))), hintText: localizations.keyword, suffixIcon: Obx(() => IconButton( tooltip: "Case Sensitive", icon: Text('Aa', style: TextStyle( fontWeight: FontWeight.w500, color: searchModel.caseSensitive.value ? primaryColor : null)), onPressed: () { searchModel.caseSensitive.value = !searchModel.caseSensitive.value; }, )), ), ), const SizedBox(height: 10), // protocol quick selectors placed under the keyword input (very compact) protocolsWidget(), const SizedBox(height: 10), // keyword scope Text(localizations.keywordSearchScope), const SizedBox(height: 10), Wrap( children: [ options('URL', Option.url), options(localizations.requestHeader, Option.requestHeader), options(localizations.requestBody, Option.requestBody), options(localizations.responseHeader, Option.responseHeader), options(localizations.responseBody, Option.responseBody), ], ), const SizedBox(height: 10), // request method row( Text('${localizations.requestMethod}:'), DropdownMenu( initialValue: searchModel.requestMethod?.name ?? localizations.all, items: HttpMethod.methods().map((e) => e.name).toList()..insert(0, localizations.all), onSelected: (String value) { searchModel.requestMethod = value == localizations.all ? null : HttpMethod.valueOf(value); }, ), ), const SizedBox(height: 10), // request type row( Text('${localizations.requestType}:'), DropdownMenu( initialValue: Maps.getKey(requestContentMap, searchModel.requestContentType) ?? localizations.all, items: requestContentMap.keys, onSelected: (String value) { searchModel.requestContentType = requestContentMap[value]; }, ), ), const SizedBox(height: 10), // response type row( Text('${localizations.responseType}:'), DropdownMenu( initialValue: Maps.getKey(responseContentMap, searchModel.responseContentType) ?? localizations.all, items: responseContentMap.keys, onSelected: (String value) { searchModel.responseContentType = responseContentMap[value]; }, ), ), const SizedBox(height: 10), // status code range row( Text('${localizations.statusCode}: '), Row(children: [ SizedBox( width: 55, height: 32, child: textField( initialValue: searchModel.statusCodeFrom?.toString(), onChanged: (val) => searchModel.statusCodeFrom = int.tryParse(val))), const Padding(padding: EdgeInsets.symmetric(horizontal: 5), child: Text(" - ")), SizedBox( width: 55, height: 32, child: textField( initialValue: searchModel.statusCodeTo?.toString(), onChanged: (val) => searchModel.statusCodeTo = int.tryParse(val))), ]), ), const SizedBox(height: 10), // duration range (ms) row( Text('${localizations.duration} (ms): '), Row(children: [ SizedBox( width: 55, height: 32, child: textField( initialValue: searchModel.durationFromMs?.toString(), onChanged: (val) => searchModel.durationFromMs = int.tryParse(val))), const Padding(padding: EdgeInsets.symmetric(horizontal: 5), child: Text(" - ")), SizedBox( width: 55, height: 32, child: textField( initialValue: searchModel.durationToMs?.toString(), onChanged: (val) => searchModel.durationToMs = int.tryParse(val))), ]), ), const SizedBox(height: 15), // action buttons Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(localizations.cancel, style: const TextStyle(fontSize: 14)), ), TextButton( onPressed: () { widget.onSearch?.call(SearchModel()); Navigator.pop(context); }, child: Text(localizations.clearSearch, style: const TextStyle(fontSize: 14)), ), TextButton( onPressed: () { widget.onSearch?.call(searchModel); Navigator.pop(context); }, child: Text(localizations.confirm, style: const TextStyle(fontSize: 14)), ), ], ), ], ), ); } Widget protocolsWidget() { Color primaryColor = ColorScheme.of(context).primary; return Wrap( spacing: 5, runSpacing: 2, children: [ FilterChip( label: const Text('HTTP'), selected: searchModel.protocols.contains(Protocol.http), showCheckmark: false, selectedColor: primaryColor.withValues(alpha: 0.12), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), labelStyle: const TextStyle(fontSize: 12), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: const VisualDensity(horizontal: -3, vertical: -3), onSelected: (sel) => setState(() { sel ? searchModel.protocols.add(Protocol.http) : searchModel.protocols.remove(Protocol.http); }), ), FilterChip( label: const Text('HTTPS'), selected: searchModel.protocols.contains(Protocol.https), showCheckmark: false, selectedColor: primaryColor.withValues(alpha: 0.12), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), labelStyle: const TextStyle(fontSize: 12), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: const VisualDensity(horizontal: -3, vertical: -3), onSelected: (sel) => setState(() { sel ? searchModel.protocols.add(Protocol.https) : searchModel.protocols.remove(Protocol.https); }), ), FilterChip( label: const Text('WS'), selected: searchModel.protocols.contains(Protocol.ws), showCheckmark: false, selectedColor: primaryColor.withValues(alpha: 0.12), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), labelStyle: const TextStyle(fontSize: 12), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: const VisualDensity(horizontal: -3, vertical: -3), onSelected: (sel) => setState(() { sel ? searchModel.protocols.add(Protocol.ws) : searchModel.protocols.remove(Protocol.ws); }), ), FilterChip( label: const Text('HTTP/1'), selected: searchModel.protocols.contains(Protocol.http1), showCheckmark: false, selectedColor: primaryColor.withValues(alpha: 0.12), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), labelStyle: const TextStyle(fontSize: 12), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: const VisualDensity(horizontal: -3, vertical: -3), onSelected: (sel) => setState(() { sel ? searchModel.protocols.add(Protocol.http1) : searchModel.protocols.remove(Protocol.http1); }), ), FilterChip( label: const Text('H2'), selected: searchModel.protocols.contains(Protocol.h2), showCheckmark: false, selectedColor: primaryColor.withValues(alpha: 0.12), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0), labelStyle: const TextStyle(fontSize: 12), materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: const VisualDensity(horizontal: -3, vertical: -3), onSelected: (sel) => setState(() { sel ? searchModel.protocols.add(Protocol.h2) : searchModel.protocols.remove(Protocol.h2); }), ), ], ); } Widget options(String title, Option option) { bool isCN = localizations.localeName == 'zh'; return Container( constraints: BoxConstraints(maxWidth: isCN ? 100 : 132, minWidth: 100, maxHeight: 33), child: Row(children: [ Text(title, style: const TextStyle(fontSize: 12)), Checkbox( visualDensity: VisualDensity.compact, value: searchModel.searchOptions.contains(option), onChanged: (val) { setState(() { val == true ? searchModel.searchOptions.add(option) : searchModel.searchOptions.remove(option); }); }) ])); } Widget row(Widget child, Widget child2) { return Row( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.center, children: [Expanded(flex: 4, child: child), Expanded(flex: 6, child: child2)]); } Widget textField({String? initialValue, final ValueChanged? onChanged, TextStyle? style}) { Color color = Theme.of(context).colorScheme.primary; return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 32), child: TextFormField( keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], onTapOutside: (event) => FocusManager.instance.primaryFocus?.unfocus(), initialValue: initialValue, onChanged: onChanged, style: style, decoration: InputDecoration( contentPadding: const EdgeInsets.only(left: 10, right: 10, top: 2, bottom: 2), border: OutlineInputBorder(borderSide: BorderSide(width: 1, color: color.withOpacity(0.3))), ), )); } } class DropdownMenu extends StatefulWidget { final String? initialValue; final Iterable items; final Function(String value) onSelected; const DropdownMenu({super.key, this.initialValue, required this.items, required this.onSelected}); @override State createState() { return DropdownMenuState(); } } class DropdownMenuState extends State { String? selectValue; @override void initState() { super.initState(); selectValue = widget.initialValue; } @override Widget build(BuildContext context) { return PopupMenuButton( tooltip: '', initialValue: selectValue, child: Wrap(runAlignment: WrapAlignment.center, children: [ Text(selectValue ?? '', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), const Icon(Icons.arrow_drop_down, size: 20) ]), onSelected: (String value) { setState(() { widget.onSelected.call(value); selectValue = value; }); }, itemBuilder: (BuildContext context) { return widget.items .map((it) => PopupMenuItem(height: 35, value: it, child: Text(it, style: const TextStyle(fontSize: 12)))) .toList(); }, ); } }