diff --git a/lib/main.dart b/lib/main.dart index b9562cf..cf65b3a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,13 +2,13 @@ import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:network/network/bin/server.dart'; -import 'package:network/ui/widgets.dart'; +import 'package:network/ui/left.dart'; +import 'package:network/ui/panel.dart'; import 'network/channel.dart'; import 'network/handler.dart'; import 'network/http/http.dart'; import 'network/util/AttributeKeys.dart'; -import 'ui/components.dart'; void main() { runApp(const MyApp()); @@ -39,33 +39,109 @@ class NetworkHomePage extends StatefulWidget { State createState() => _NetworkHomePagePageState(); } -class _NetworkHomePagePageState extends State implements EventListener { - LinkedHashMap> containerMap = LinkedHashMap>(); +class DomainWidget extends StatefulWidget { + final _DomainWidgetState _state = _DomainWidgetState(); + final NetworkTabController panel; - Set expandedHosts = {}; + DomainWidget({super.key, required this.panel}); - @override - void onRequest(Channel channel, HttpRequest request) { - HostAndPort hostAndPort = channel.getAttribute(AttributeKeys.HOST_KEY); - var list = containerMap[hostAndPort]; - if (list == null) { - list = [request]; - setState(() { - containerMap; - }); - containerMap[hostAndPort] = list; - } else { - list.add(request); - } + void add(Channel channel, HttpRequest request) { + _state.add(channel, request); + } + + void addResponse(Channel channel, HttpResponse response) { + _state.addResponse(channel, response); + } + + void clean() { + _state.clean(); } @override - void onResponse(Channel channel, HttpResponse response) {} + State createState() { + return _state; + } +} + +class _DomainWidgetState extends State { + LinkedHashMap> containerMap = + LinkedHashMap>(); + + @override + Widget build(BuildContext context) { + var map = containerMap.values.map((e) => ValueListenableBuilder( + valueListenable: e, + builder: (context, value, child) { + return _show(value); + })); + + return ListView(children: map.toList()); + } + + ///添加请求 + void add(Channel channel, HttpRequest request) { + HostAndPort hostAndPort = channel.getAttribute(AttributeKeys.HOST_KEY); + ValueNotifier? valueNotifier = containerMap[hostAndPort]; + var listURI = RowURI(request, widget.panel); + if (valueNotifier != null) { + valueNotifier.value.addBody(channel.id, listURI); + valueNotifier.value = valueNotifier.value.copy(); + return; + } + + var headerBody = HeaderBody(hostAndPort.url); + valueNotifier = ValueNotifier(headerBody); + headerBody.addBody(channel.id, listURI); + + setState(() { + containerMap[hostAndPort] = valueNotifier!; + }); + } + + ///添加响应 + void addResponse(Channel channel, HttpResponse response) { + HostAndPort hostAndPort = channel.getAttribute(AttributeKeys.HOST_KEY); + ValueNotifier? valueNotifier = containerMap[hostAndPort]; + if (valueNotifier != null && valueNotifier.value.getBody(channel.id) != null) { + var body = valueNotifier.value.getBody(channel.id); + body?.add(response); + return; + } + } + + void clean() { + setState(() { + containerMap.forEach((key, value) { + value.dispose(); + }); + containerMap.clear(); + }); + } + + Widget _show(Widget widget) { + return AnimatedOpacity(opacity: 1.0, duration: const Duration(seconds: 2), child: widget); + } +} + +class _NetworkHomePagePageState extends State implements EventListener { + late DomainWidget domainWidget; + final NetworkTabController panel = NetworkTabController(); + + @override + void onRequest(Channel channel, HttpRequest request) { + domainWidget.add(channel, request); + } + + @override + void onResponse(Channel channel, HttpResponse response) { + domainWidget.addResponse(channel, response); + } @override void initState() { - super.initState(); print("initState"); + super.initState(); + domainWidget = DomainWidget(panel: panel); start(listener: this); } @@ -73,102 +149,14 @@ class _NetworkHomePagePageState extends State implements EventL Widget build(BuildContext context) { return Scaffold( appBar: AppBar( + leading: IconButton(onPressed: () => domainWidget.clean(), icon: const Icon(Icons.cleaning_services)), backgroundColor: Theme.of(context).colorScheme.inversePrimary, title: Text(widget.title), ), body: Row(children: [ - SizedBox(width: 420, child: ListView(children: _buildHosts())), + SizedBox(width: 420, child: domainWidget), const Spacer(), - Expanded(flex: 100, child: NetworkTabController()), + Expanded(flex: 100, child: Visibility(visible: true, child: domainWidget.panel)), ])); } - - Widget _row(HostAndPort host) { - bool selected = expandedHosts.contains(host.url); - return ListTile( - leading: Icon(selected ? Icons.arrow_drop_down : Icons.arrow_right, size: 16), - dense: true, - selected: selected, - horizontalTitleGap: 0, - visualDensity: const VisualDensity(vertical: -3.6), - title: Text(host.url, textAlign: TextAlign.left), - onTap: () { - if (!expandedHosts.remove(host.url)) { - expandedHosts.add(host.url); - } - setState(() { - expandedHosts; - }); - }); - } - - List _buildHosts() { - print(containerMap.keys); - List list = []; - for (var host in containerMap.keys) { - list.add(_row(host)); - - if (expandedHosts.contains(host.url)) { - containerMap[host]?.forEach((element) { - print(element); - list.add(ListURI(leading: Icons.html, text: '${element.method.name} ${Uri.parse(element.uri).path}')); - }); - } - } - return list; - // return [ - // ListTile( - // leading: const Icon(Icons.arrow_drop_down), - // dense: true, - // title: const Text('https://dmall.com', textAlign: TextAlign.left), - // selected: true, - // onTap: () {}), - // const ListURI(leading: Icons.image_outlined, text: 'POST /private/browser/stats/haha'), - // const ListURI(leading: Icons.javascript_sharp, text: 'GET /private/browser/stats/haha'), - // const ListURI(leading: Icons.css, text: 'GET /private/browser/stats/haha'), - // ListTile( - // leading: const Icon( - // size: 15, - // Icons.document_scanner, - // color: Colors.red, - // ), - // title: const Text('POST /private/browser/stats', textAlign: TextAlign.left), - // textColor: Colors.red, - // trailing: const Icon(Icons.chevron_right), - // visualDensity: const VisualDensity(vertical: -4), - // dense: true, - // contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 50.0), - // onTap: () {}), - // const Divider(), - // ListTile( - // leading: const Icon(Icons.expand_more), - // dense: true, - // title: const Text('https://baidu.com', textAlign: TextAlign.left), - // onTap: () {}), - // _buildRow(), - // const Divider(), - // ]; - } - - Widget _buildRow() { - final ScrollController scrollController = ScrollController(); - return Scrollbar( - controller: scrollController, - thumbVisibility: true, - trackVisibility: true, - child: SingleChildScrollView( - controller: scrollController, - scrollDirection: Axis.horizontal, - child: Container( - margin: const EdgeInsets.only(left: 16), - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RowURI(leading: Icons.image, text: "POST /private/browser/stats/private/browser/stats"), - RowURI(leading: Icons.javascript_sharp, text: "GET /private/browser/stats.js"), - RowURI(leading: Icons.css, text: "GET /openapi/documents/oristartguid.css"), - ], - )), - )); - } } diff --git a/lib/network/bin/server.dart b/lib/network/bin/server.dart index b75e8cb..64a6d92 100644 --- a/lib/network/bin/server.dart +++ b/lib/network/bin/server.dart @@ -4,6 +4,7 @@ import 'dart:io'; import '../channel.dart'; import '../handler.dart'; import '../http/codec.dart'; +import '../util/logger.dart'; Future main() async { start(); @@ -15,6 +16,7 @@ Future start({EventListener? listener}) async { ..initChannel((channel) { channel.pipeline.handle(HttpRequestCodec(), HttpResponseCodec(), HttpChannelHandler(listener: listener)); }); + log.i("listen on $port"); await server.bind().then((value) => {setSystemProxy(port)}); } @@ -27,8 +29,8 @@ void setSystemProxy(int port) { // .then((ProcessResult results) { // print(results.stdout); // }); - // Process.run('networksetup', ['-setwebproxy', 'Wi-Fi', '127.0.0.1', port.toString()]).then((ProcessResult results) { - // print(results.stdout); - // }); + Process.run('networksetup', ['-setwebproxy', 'Wi-Fi', '127.0.0.1', port.toString()]).then((ProcessResult results) { + print(results.stdout); + }); } } diff --git a/lib/network/handler.dart b/lib/network/handler.dart index df0b9b4..979a82d 100644 --- a/lib/network/handler.dart +++ b/lib/network/handler.dart @@ -84,7 +84,7 @@ class HttpChannelHandler extends ChannelHandler { var hostAndPort = getHostAndPort(httpRequest); clientChannel.putAttribute(AttributeKeys.HOST_KEY, hostAndPort); - var proxyHandler = HttpResponseProxyHandler(clientChannel); + var proxyHandler = HttpResponseProxyHandler(clientChannel, listener: listener); var proxyChannel = await HttpClients.connect(hostAndPort, proxyHandler); //https代理新建连接请求 @@ -104,14 +104,17 @@ class HttpResponseProxyHandler extends ChannelHandler { /// 排除的后缀 不打印日志 final Set excludeContent = HashSet.from(["javascript", "text/css", "application/font-woff", "image"]); - HttpResponseProxyHandler(this.clientChannel); + EventListener? listener; + + HttpResponseProxyHandler(this.clientChannel, {this.listener}); @override void channelRead(Channel channel, HttpResponse msg) { String contentType = msg.headers.contentType; if (excludeContent.every((element) => !contentType.contains(element))) { - // log.i("[${clientChannel.id}] Response ${String.fromCharCodes(msg.body ?? [])}"); + // log.i("[${clientChannel.id}] Response ${ String.fromCharCodes(msg.body ?? [])}"); } + listener?.onResponse(clientChannel, msg); //发送给客户端 clientChannel.write(msg); } diff --git a/lib/network/http/http.dart b/lib/network/http/http.dart index 6ab8247..de56307 100644 --- a/lib/network/http/http.dart +++ b/lib/network/http/http.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:io'; + import 'http_headers.dart'; ///定义HTTP消息的接口,为HttpRequest和HttpResponse提供公共属性。 @@ -12,10 +15,17 @@ abstract class HttpMessage { HttpMessage(this.protocolVersion); String get bodyAsString { - if (body == null) { + if (body == null || body?.isEmpty == true) { return ""; } - return String.fromCharCodes(body!); + try { + if (headers.isGzip) { + return utf8.decode(gzip.decode(body!)); + } + return utf8.decode(body!); + } catch (e) { + return String.fromCharCodes(body!); + } } } @@ -132,6 +142,10 @@ class HttpStatus { HttpStatus(this.code, this.reasonPhrase); + bool isSuccessful() { + return code >= 200 && code < 300; + } + @override String toString() { return 'HttpResponseStatus{code: $code, reasonPhrase: $reasonPhrase}'; diff --git a/lib/network/util/CertificateManager.dart b/lib/network/util/CertificateManager.dart index d2138b0..6e74669 100644 --- a/lib/network/util/CertificateManager.dart +++ b/lib/network/util/CertificateManager.dart @@ -2,6 +2,7 @@ import 'dart:core'; import 'dart:io'; import 'package:basic_utils/basic_utils.dart'; +import 'package:flutter/services.dart'; Future main() async { var securityContext = await CertificateManager.getCertificateContext('www.baidu.com'); @@ -64,7 +65,7 @@ class CertificateManager { var csr = X509Utils.generateRsaCsrPem(x509Subject, caPriKey, serverPubKey as RSAPublicKey, san: [host]); Map issuer = Map.from(_caCert.tbsCertificate!.subject); - var csrPem = X509Utils.generateSelfSignedCertificate(caPriKey, csr, 3650, sans: [host], issuer: issuer); + var csrPem = X509Utils.generateSelfSignedCertificate(caPriKey, csr, 365, sans: [host], issuer: issuer); return csrPem; } @@ -73,13 +74,13 @@ class CertificateManager { return; } //从项目目录加入ca根证书 - var caPem = await File('assets/certs/ca.crt').readAsString(); + var caPem = await rootBundle.loadString('assets/certs/ca.crt'); _caCert = X509Utils.x509CertificateFromPem(caPem); //根据CA证书subject来动态生成目标服务器证书的issuer和subject //从项目目录加入ca私钥 - var privateBytes = await File('assets/certs/ca_private.der').readAsBytes(); - _caPriKey = CryptoUtils.rsaPrivateKeyFromDERBytes(privateBytes); + var privateBytes = await rootBundle.load('assets/certs/ca_private.der'); + _caPriKey = CryptoUtils.rsaPrivateKeyFromDERBytes(privateBytes.buffer.asUint8List()); _initialized = true; } diff --git a/lib/ui/components.dart b/lib/ui/components.dart deleted file mode 100644 index b249fff..0000000 --- a/lib/ui/components.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; - -class ListURI extends StatefulWidget { - final IconData? leading; - final String text; - final Color? color; - final IconData trailing; - - const ListURI( - {Key? key, this.leading, required this.text, this.color = Colors.green, this.trailing = Icons.chevron_right}) - : super(key: key); - - @override - State createState() => _ListURIState(); -} - -class _ListURIState extends State { - @override - Widget build(BuildContext context) { - return ListTile( - leading: Icon(widget.leading, size: 15, color: widget.color), - title: Text(widget.text, overflow: TextOverflow.ellipsis, maxLines: 1), - trailing: Icon(widget.trailing), - dense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 50.0), - onTap: () {}); - } -} - -class RowURI extends StatefulWidget { - final IconData? leading; - final String text; - final IconData trailing; - - const RowURI({Key? key, this.leading, required this.text, this.trailing = Icons.arrow_right}) : super(key: key); - - @override - State createState() { - return _RowURIState(); - } -} - -class _RowURIState extends State { - @override - Widget build(BuildContext context) { - return Row(children: [ - TextButton.icon( - icon: Icon(widget.leading, size: 16), - onPressed: () { - print("hello"); - }, - label: Text(widget.text, style: const TextStyle(color: Colors.black87)), - ), - const Positioned(right: 10, child: Icon(Icons.chevron_right)) - ]); - } -} - -class IconText extends StatefulWidget { - const IconText({Key? key, this.leading, required this.text, this.color, this.trailing}) : super(key: key); - - final Widget? leading; - final String text; - final Color? color; - final Widget? trailing; - - @override - State createState() => _IconTextState(); -} - -class _IconTextState extends State { - @override - Widget build(BuildContext context) { - return Row( - children: [ - widget.leading ?? const SizedBox(), - Text(widget.text), - widget.trailing ?? const SizedBox(), - ], - ); - } -} diff --git a/lib/ui/left.dart b/lib/ui/left.dart new file mode 100644 index 0000000..c8f7820 --- /dev/null +++ b/lib/ui/left.dart @@ -0,0 +1,112 @@ +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:network/network/http/http.dart'; +import 'package:network/ui/panel.dart'; + +///标题和内容布局 标题是域名 内容是域名下请求 +class HeaderBody extends StatefulWidget { + final Map map = HashMap(); + + final String header; + final Queue _body = Queue(); + final bool selected; + + HeaderBody(this.header, {Key? key, this.selected = false}) : super(key: key); + + ///添加请求 + void addBody(String key, RowURI widget) { + _body.addFirst(widget); + map[key] = widget; + } + + RowURI? getBody(String key) { + return map[key]; + } + + //复制 + HeaderBody copy() { + var headerBody = HeaderBody(header, selected: selected); + headerBody._body.addAll(_body); + headerBody.map.addAll(map); + return this; + } + + @override + State createState() { + return _HeaderBodyState(); + } +} + +class _HeaderBodyState extends State { + late bool selected; + + @override + void initState() { + super.initState(); + selected = widget.selected; + } + + @override + Widget build(BuildContext context) { + return Column(children: [ + _hostWidget(widget.header), + Visibility(visible: selected, child: Column(children: widget._body.toList())) + ]); + } + + Widget _hostWidget(String title) { + return ListTile( + leading: Icon(selected ? Icons.arrow_drop_down : Icons.arrow_right, size: 16), + dense: true, + selected: selected, + horizontalTitleGap: 0, + visualDensity: const VisualDensity(vertical: -3.6), + title: Text(title, textAlign: TextAlign.left), + onTap: () { + setState(() { + selected = !selected; + }); + }); + } +} + +class RowURI extends StatefulWidget { + final Color? color; + final HttpRequest request; + HttpResponse? response; + final NetworkTabController panel; + + final _RowURIState _state = _RowURIState(); + + RowURI(this.request, this.panel, {Key? key, this.color = Colors.green}) : super(key: key); + + @override + State createState() => _state; + + void add(HttpResponse response) { + this.response = response; + } +} + +class _RowURIState extends State { + bool selected = false; + + @override + Widget build(BuildContext context) { + var request = widget.request; + var leading = widget.response == null ? Icons.http : Icons.http; + var title = '${request.method.name} ${Uri.parse(request.uri).path}'; + + return ListTile( + leading: Icon(leading, size: 15, color: widget.color), + title: Text(title, overflow: TextOverflow.ellipsis, maxLines: 1), + trailing: const Icon(Icons.chevron_right), + dense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 50.0), + onTap: () { + selected = !selected; + widget.panel.change(widget.request, widget.response); + }); + } +} diff --git a/lib/ui/panel.dart b/lib/ui/panel.dart new file mode 100644 index 0000000..8cc5cb5 --- /dev/null +++ b/lib/ui/panel.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:network/network/http/http.dart'; + +class NetworkTabController extends StatefulWidget { + final tabs = [ + const Tab(child: Text('General', style: TextStyle(fontSize: 18))), + const Tab(child: Text('Request', style: TextStyle(fontSize: 18))), + const Tab(child: Text('Response', style: TextStyle(fontSize: 18))), + const Tab(child: Text('Cookies', style: TextStyle(fontSize: 18))), + ]; + + final _NetworkTabState _state = _NetworkTabState(); + HttpRequest? request; + HttpResponse? response; + + NetworkTabController({super.key}); + + void change(HttpRequest request, HttpResponse? response) { + _state.setState(() { + this.request = request; + this.response = response; + }); + } + + @override + State createState() { + return _state; + } +} + +class _NetworkTabState extends State { + @override + Widget build(BuildContext context) { + if (widget.request == null) { + return const SizedBox(); + } + + return DefaultTabController( + length: widget.tabs.length, + child: Scaffold( + appBar: AppBar(title: TabBar(tabs: widget.tabs)), + body: TabBarView( + children: [ + general(), + request(), + response(), + ListView(children: const [Text("Cookies")]) + ], + ), + )); + } + + Widget general() { + return ExpansionTile( + title: const Text("General", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + initiallyExpanded: true, + childrenPadding: const EdgeInsets.all(20), + children: [ + Row(children: [ + const Expanded(flex: 1, child: SelectableText("Request URL:")), + Expanded(flex: 4, child: SelectableText(widget.request?.uri ?? '')) + ]), + const SizedBox(height: 20), + Row(children: [ + const Expanded(flex: 1, child: SelectableText("Status Code:")), + Expanded(flex: 4, child: SelectableText(widget.response?.status.code.toString() ?? '')) + ]) + ]); + } + + Widget request() { + var headers = []; + widget.request?.headers.forEach((name, value) { + headers.add(Row(children: [ + Expanded(flex: 2, child: SelectableText('$name:')), + Expanded(flex: 4, child: SelectableText(value)), + const SizedBox(height: 20), + ])); + }); + + ExpansionTile? bodyWidgets; + if (widget.request?.body?.isNotEmpty == true) { + bodyWidgets = ExpansionTile( + title: const Text("Request Body", style: TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: true, + shape: const Border(), + childrenPadding: const EdgeInsets.all(20), + children: [ + SelectableText.rich( + TextSpan(text: widget.request?.bodyAsString, style: const TextStyle(color: Colors.black))) + ]); + } + + return ListView(children: [ + ExpansionTile( + title: const Text("Request Headers", style: TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: true, + shape: const Border(), + childrenPadding: const EdgeInsets.all(20), + children: headers), + const Divider(), + bodyWidgets ?? const SizedBox() + ]); + } + + Widget response() { + var headers = []; + widget.response?.headers.forEach((name, value) { + headers.add(Row(children: [ + Expanded(flex: 2, child: SelectableText('$name:')), + Expanded(flex: 4, child: SelectableText(value)), + const SizedBox(height: 20), + ])); + }); + + return ListView(children: [ + ExpansionTile( + title: const Text("Response Headers", style: TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: true, + shape: const Border(), + childrenPadding: const EdgeInsets.all(20), + children: headers), + const Divider(), + ExpansionTile( + title: const Text("Response Body", style: TextStyle(fontWeight: FontWeight.bold)), + initiallyExpanded: true, + shape: const Border(), + childrenPadding: const EdgeInsets.all(20), + children: [ + SelectableText.rich( + TextSpan(text: widget.response?.bodyAsString, style: const TextStyle(color: Colors.black)) + ) + ]) + ]); + } +} diff --git a/lib/ui/widgets.dart b/lib/ui/widgets.dart deleted file mode 100644 index b5c7d68..0000000 --- a/lib/ui/widgets.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; - -class NetworkTabController extends DefaultTabController { - static const myTabs = [ - Tab(child: Text('General', style: TextStyle(fontSize: 18))), - Tab(child: Text('Request', style: TextStyle(fontSize: 18))), - Tab(child: Text('Response', style: TextStyle(fontSize: 18))), - Tab(child: Text('Cookies', style: TextStyle(fontSize: 18))), - ]; - - NetworkTabController({super.key}) - : super( - length: 4, - child: Scaffold( - appBar: AppBar( - title: const TabBar(tabs: myTabs), - ), - body: TabBarView( - children: [ - ListView(children: const [ - Text.rich(TextSpan(children: [ - TextSpan(text: "Home: "), - TextSpan(text: "https://flutterchina.club", style: TextStyle(color: Colors.blue)), - ])) - ]), - Container( - padding: const EdgeInsets.all(20), - child: const Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('General', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), - Row(children: [ - Expanded(flex: 1, child: Text("Request URL:")), - Expanded( - flex: 4, - child: Text( - "https://googleads.g.doubleclick.net/pagead/html/r20230601/r20190131/zrt_lookup.html")) - ]), - SizedBox(height: 10), //保留间距 - Row(children: [ - Expanded(flex: 1, child: Text("Request Method:")), - Expanded(flex: 4, child: Text("GET")) - ]), - SizedBox(height: 10), //保留间距 - Row(children: [ - Expanded(flex: 1, child: Text("Status Code:")), - Expanded(flex: 4, child: Text("200")) - ]), - SizedBox(height: 10), //保留间距 - Row(children: [ - Expanded(flex: 1, child: Text("Remote Address:")), - Expanded(flex: 4, child: Text("127.0.0.1:8080")) - ]) - ])), - const ExpansionTile( - title: Text("General", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)), - initiallyExpanded: true, - childrenPadding: EdgeInsets.all(20), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Expanded(flex: 1, child: Text("Request URL:")), - Expanded( - flex: 4, - child: Text( - "https://googleads.g.doubleclick.net/pagead/html/r20230601/r20190131/zrt_lookup.html")) - ]), - SizedBox( - height: 20, - ), - Row(children: [ - Expanded(flex: 1, child: Text("Status Code:")), - Expanded(flex: 4, child: Text("200")) - ]) - ]), - ListView(children: const [Text("Cookies")]) - ], - ), - )); -}