From fabb0767b5826eae5684f16f5e5da8184cd1d890 Mon Sep 17 00:00:00 2001 From: ChronosX88 Date: Fri, 15 Apr 2022 05:46:18 +0300 Subject: [PATCH] Implement thread list loading --- lib/main.dart | 85 ++++-------------- lib/newsgroup_list_view.dart | 44 +++++++--- lib/nntp_client.dart | 56 +++++++++++- lib/thread_list_model.dart | 50 +++++++++++ lib/thread_list_view.dart | 150 ++++++++++++++++++++++++++++++++ lib/thread_screen.dart | 17 ++++ pubspec.lock | 162 +++++++++++++++++++++++++++++++++++ pubspec.yaml | 3 + 8 files changed, 484 insertions(+), 83 deletions(-) create mode 100644 lib/thread_list_model.dart create mode 100644 lib/thread_list_view.dart create mode 100644 lib/thread_screen.dart diff --git a/lib/main.dart b/lib/main.dart index 6a680a4..53fb761 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:wind/newsgroup_list_view.dart'; import 'package:wind/nntp_client.dart'; +import 'package:wind/thread_list_view.dart'; +import 'package:wind/thread_screen.dart'; + +import 'thread_list_model.dart'; void main() { - runApp(MyApp()); + runApp(MultiProvider(providers: [ + Provider(create: ((context) => NNTPClient("localhost:1120"))), + ChangeNotifierProxyProvider( + create: (context) => ThreadListModel(), + update: (context, client, model) { + model!.client = client; + return model; + }), + ], child: MyApp())); } class MyApp extends StatelessWidget { @@ -16,6 +29,7 @@ class MyApp extends StatelessWidget { primarySwatch: Colors.indigo, ), home: MyHomePage(title: 'Wind'), + routes: {'/thread': (context) => ThreadScreen()}, ); } } @@ -72,14 +86,15 @@ class _MyHomePageState extends State { Expanded( child: Builder( builder: (context) => - NewsgroupListView(nntpClient))), + NewsgroupListView(client: nntpClient))), ], ), ), const VerticalDivider(thickness: 1, width: 1), Expanded( child: Center( - child: ThreadsListView(), + child: Consumer( + builder: ((context, value, child) => ThreadListView())), )) ], ), @@ -87,67 +102,3 @@ class _MyHomePageState extends State { )); } } - -class ThreadsListView extends StatefulWidget { - @override - State createState() => ThreadsListViewState(); -} - -class ThreadsListViewState extends State { - String currentGroup = "no group selected"; - - @override - Widget build(BuildContext context) { - return Container(width: 640, child: Center(child: Text(currentGroup))); - } -} - -class ThreadListItemView extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Card( - elevation: 5, - child: InkWell( - splashColor: Colors.indigo.withAlpha(30), - onTap: () => {}, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - child: Text( - "A question for those who watched The Matrix in 1999.", - style: TextStyle(fontWeight: FontWeight.bold, fontSize: 21), - ), - margin: EdgeInsets.only(top: 16, left: 16, right: 16), - ), - Container( - child: Row( - children: [ - Text( - "@sample_user", - style: TextStyle( - fontWeight: FontWeight.normal, - color: Colors.blue, - fontSize: 15), - ), - SizedBox(width: 5), - Text( - "14/04/22 Чтв 00:57:52", - style: TextStyle(fontSize: 15), - ), - ], - ), - margin: EdgeInsets.only(top: 5, bottom: 2, left: 16, right: 16), - ), - Container( - child: Text( - "So I'm 16 years old, and I finally watched the first Matrix movie yesterday, and I found it amazing. Everything about it was wonderful. Anyway, I watched it with my mom and she was gushing over it again, telling me about how it blew her away when she watched it in the theaters when it came out.\nAnd that inspired me to ask this question. To any of you in this subreddit who watched the movie when it was released, do you have any fond memories or stories about your experience in the theater that you can tell me about?\nI'd really like to hear them. 👍", - style: TextStyle(fontSize: 17)), - margin: EdgeInsets.all(16), - ) - ], - ), - )); - } -} diff --git a/lib/newsgroup_list_view.dart b/lib/newsgroup_list_view.dart index 855b8dd..e33f43d 100644 --- a/lib/newsgroup_list_view.dart +++ b/lib/newsgroup_list_view.dart @@ -1,10 +1,22 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:wind/nntp_client.dart'; +import 'package:wind/thread_list_model.dart'; + +class NewsgroupListView extends StatefulWidget { + NewsgroupListView({Key? key, required this.client}) : super(key: key); -class NewsgroupListView extends StatelessWidget { final NNTPClient client; - NewsgroupListView(this.client); + @override + State createState() => new NewsgroupListViewState(client); +} + +class NewsgroupListViewState extends State { + late NNTPClient client; + int _selectedIndex = -1; + + NewsgroupListViewState(this.client); @override Widget build(BuildContext context) { @@ -13,7 +25,7 @@ class NewsgroupListView extends StatelessWidget { builder: (context, snapshot) { if (snapshot.hasData) { List data = snapshot.data!; - return _newgroupListView(data); + return _newsgroupListView(data); } else if (snapshot.hasError) { return Text("${snapshot.error}"); } @@ -26,16 +38,22 @@ class NewsgroupListView extends StatelessWidget { return await client.getNewsGroupList(); } - ListView _newgroupListView(List data) { + Widget _newsgroupListView(List data) { return ListView.builder( - itemCount: data.length, - itemBuilder: (context, index) { - return ListTile( - style: ListTileStyle.drawer, - title: Text(data[index].name), - subtitle: Text(data[index].description), - onTap: () => {}); - }, - ); + itemCount: data.length, + itemBuilder: (context, index) { + return ListTile( + style: ListTileStyle.drawer, + title: Text(data[index].name), + subtitle: Text(data[index].description), + selected: index == _selectedIndex, + onTap: () { + setState(() => _selectedIndex = index); + var model = context.read(); + model + .selectNewsgroup(data[index].name) + .whenComplete(() => null); + }); + }); } } diff --git a/lib/nntp_client.dart b/lib/nntp_client.dart index 0686c54..84d1d73 100644 --- a/lib/nntp_client.dart +++ b/lib/nntp_client.dart @@ -1,10 +1,15 @@ import 'dart:async'; import 'dart:collection'; +import 'package:enough_mail/mime_message.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:tuple/tuple.dart'; class NNTPClient { late WebSocketChannel _channel; final Queue<_NNTPCommand> commandQueue = new Queue(); + final List tempBuffer = []; + + String? currentGroup; NNTPClient(String addr) { _channel = WebSocketChannel.connect( @@ -12,14 +17,30 @@ class NNTPClient { ); _channel.stream.listen((data) { - if ((data as String).contains("201")) { + if ((data as String).startsWith("201")) { // skip welcome message return; } - var command = commandQueue.removeFirst(); var resp = data.toString(); var respLines = resp.split("\r\n"); - respLines.removeWhere((element) => element == ""); + if (respLines.last == "") respLines.removeLast(); // trailing empty line + + if ((respLines.length > 1 || tempBuffer.isNotEmpty) && + respLines.last.codeUnits.last != ".".codeUnits.first) { + // if it's multiline response and it doesn't contain dot in the end + // then looks like we need to wait for next message to concatenate with current msg + tempBuffer.add(resp); + return; + } + + if (tempBuffer.isNotEmpty) { + tempBuffer.add(resp); + resp = tempBuffer.join(); + respLines = resp.split("\r\n"); + respLines.removeLast(); // trailing empty line + tempBuffer.clear(); + } + var command = commandQueue.removeFirst(); var respCode = int.parse(respLines[0].split(" ")[0]); command.responseCompleter.complete(_CommandResponse(respCode, respLines)); }); @@ -76,6 +97,35 @@ class NNTPClient { return l; } + + Future selectGroup(String name) async { + await _sendCommand("GROUP", [name]).then((value) => {currentGroup = name}); + } + + Future>> getNewThreads( + int perPage, int pageNum) async { + if (currentGroup == null) throw new ArgumentError("current group is null"); + + List> threads = []; + + var newThreadList = await _sendCommand( + "NEWTHREADS", [perPage.toString(), pageNum.toString()]); + + newThreadList.lines.removeAt(0); + newThreadList.lines.removeLast(); // remove dot + + await Future.forEach(newThreadList.lines, (element) async { + await _sendCommand("ARTICLE", [element]).then((response) { + response.lines.removeAt(0); + response.lines.removeLast(); + var rawMsg = response.lines.join("\r\n"); + threads + .add(Tuple2(int.parse(element), MimeMessage.parseFromText(rawMsg))); + }); + }); + + return threads; + } } class _NNTPCommand { diff --git a/lib/thread_list_model.dart b/lib/thread_list_model.dart new file mode 100644 index 0000000..028a3ee --- /dev/null +++ b/lib/thread_list_model.dart @@ -0,0 +1,50 @@ +import 'package:flutter/cupertino.dart'; +import 'package:wind/nntp_client.dart'; +import 'package:wind/thread_list_view.dart'; + +class ThreadListModel extends ChangeNotifier { + String currentGroup = ""; + NNTPClient? client; + Map>> threads = {}; + + Future selectNewsgroup(String name) async { + if (currentGroup == name) return; + + currentGroup = name; + await client!.selectGroup(name); + threads.putIfAbsent(name, () => {}); + + notifyListeners(); + } + + Future> getNewThreads( + int perPage, int pageNum, bool clearCache) async { + if (currentGroup == "") return []; + List items = []; + + if (clearCache) { + threads[currentGroup]?.clear(); + } + + if (threads[currentGroup]!.containsKey(pageNum)) { + items.addAll(threads[currentGroup]![pageNum]!); + } else { + var resp = await client!.getNewThreads(perPage, pageNum); + resp.forEach((pair) { + var number = pair.item1; + var msg = pair.item2; + items.add(ThreadItem( + msg.getHeaderValue("Message-Id")!, + number, + msg.getHeaderValue("Subject")!, + msg.getHeaderValue("From")!, + msg.getHeaderValue("Date")!, + msg.decodeTextPlainPart()!)); + }); + + threads[currentGroup]![pageNum] = items; + } + + return items; + } +} diff --git a/lib/thread_list_view.dart b/lib/thread_list_view.dart new file mode 100644 index 0000000..bf5191b --- /dev/null +++ b/lib/thread_list_view.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:wind/thread_list_model.dart'; + +class ThreadListView extends StatefulWidget { + @override + State createState() => ThreadListViewState(); +} + +class ThreadListViewState extends State { + List _items = []; + int _pageNum = 0; + String _curGroup = ""; + + @override + Widget build(BuildContext context) { + return Container( + width: 640, + padding: EdgeInsets.only(top: 16), + child: FutureBuilder>( + future: _fetchThreadList(context), + builder: (context, snapshot) { + if (snapshot.hasData && + snapshot.connectionState != ConnectionState.waiting) { + List data = List.from(snapshot.data!); + _items.addAll(data); + if (_items.isNotEmpty && + _items.last.number != -100500 && + data.isNotEmpty) + _items.add(ThreadItem("", -100500, "", "", "", + "")); // magic item (for button "load more") + return _curGroup != "" + ? _threadView() + : Center( + child: Text("Newsgroup is not selected", + style: TextStyle( + fontWeight: FontWeight.bold, fontSize: 16))); + } else if (snapshot.hasError) { + return Text("${snapshot.error}"); + } + return Center(child: CircularProgressIndicator()); + }, + )); + } + + Future> _fetchThreadList(BuildContext context) async { + var model = context.read(); + if (model.currentGroup != _curGroup) { + _items.clear(); + _curGroup = model.currentGroup; + _pageNum = 0; + } + return await model.getNewThreads(10, _pageNum, false); + } + + Widget _threadView() { + return _items.isNotEmpty + ? ListView.builder( + itemCount: _items.length, + itemBuilder: (context, index) { + if (_items[index].number == -100500) { + return Container( + height: 100, + padding: EdgeInsets.all(20), + child: TextButton( + style: ButtonStyle( + foregroundColor: + MaterialStateProperty.all(Colors.blue), + ), + onPressed: () { + setState(() { + _pageNum += 1; + _items.removeLast(); + }); + }, + child: Text('Load more'), + ), + ); + } else + return ThreadListItemView(item: _items[index]); + }) + : Center( + child: Text("This newsgroup is empty", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))); + } +} + +class ThreadListItemView extends StatelessWidget { + const ThreadListItemView({Key? key, required this.item}) : super(key: key); + + final ThreadItem item; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 5, + child: InkWell( + splashColor: Colors.indigo.withAlpha(30), + onTap: () => Navigator.pushNamed(context, "/thread"), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + child: Text( + item.subject, + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 21), + ), + margin: EdgeInsets.only(top: 16, left: 16, right: 16), + ), + Container( + child: Row( + children: [ + Text( + item.author, + style: TextStyle( + fontWeight: FontWeight.normal, + color: Colors.blue, + fontSize: 15), + ), + SizedBox(width: 5), + Text( + item.date, + style: TextStyle(fontSize: 15), + ), + ], + ), + margin: EdgeInsets.only(top: 5, bottom: 2, left: 16, right: 16), + ), + Container( + child: Text(item.body, style: TextStyle(fontSize: 17)), + margin: EdgeInsets.all(16), + ) + ], + ), + )); + } +} + +class ThreadItem { + final String id; + final int number; + final String subject; + final String author; + final String date; + final String body; + + ThreadItem( + this.id, this.number, this.subject, this.author, this.date, this.body); +} diff --git a/lib/thread_screen.dart b/lib/thread_screen.dart new file mode 100644 index 0000000..3805a3d --- /dev/null +++ b/lib/thread_screen.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class ThreadScreen extends StatefulWidget { + @override + State createState() => ThreadScreenState(); +} + +class ThreadScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("Thread"), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 850bc41..25bf6ab 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,20 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" async: dependency: transitive description: @@ -8,6 +22,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.9.0" + basic_utils: + dependency: transitive + description: + name: basic_utils + url: "https://pub.dartlang.org" + source: hosted + version: "3.9.4" boolean_selector: dependency: transitive description: @@ -43,6 +64,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.16.0" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" crypto: dependency: transitive description: @@ -57,6 +85,41 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.4" + encrypt: + dependency: transitive + description: + name: encrypt + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.1" + enough_convert: + dependency: transitive + description: + name: enough_convert + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" + enough_mail: + dependency: "direct main" + description: + name: enough_mail + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.6" + enough_serialization: + dependency: transitive + description: + name: enough_serialization + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.0" + event_bus: + dependency: transitive + description: + name: event_bus + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" fake_async: dependency: transitive description: @@ -74,6 +137,48 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: transitive + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.13.4" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + intl: + dependency: transitive + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.4" + json_annotation: + dependency: transitive + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "4.4.0" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" matcher: dependency: transitive description: @@ -95,6 +200,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.7.0" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" path: dependency: transitive description: @@ -102,6 +214,41 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.8.1" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.11.1" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.0" + pointycastle: + dependency: transitive + description: + name: pointycastle + url: "https://pub.dartlang.org" + source: hosted + version: "3.5.2" + provider: + dependency: "direct main" + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.2" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1+1" sky_engine: dependency: transitive description: flutter @@ -149,6 +296,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.9" + tuple: + dependency: "direct main" + description: + name: tuple + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" typed_data: dependency: transitive description: @@ -170,5 +324,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "5.4.1" sdks: dart: ">=2.17.0-0 <3.0.0" + flutter: ">=1.16.0" diff --git a/pubspec.yaml b/pubspec.yaml index 53cf965..f6da93e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,9 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 web_socket_channel: ^2.1.0 + provider: ^6.0.2 + enough_mail: ^1.3.6 + tuple: ^2.0.0 dev_dependencies: flutter_test: