Implement thread list loading

This commit is contained in:
ChronosX88 2022-04-15 05:46:18 +03:00
parent b6a8a9fa96
commit fabb0767b5
Signed by: ChronosXYZ
GPG Key ID: 085A69A82C8C511A
8 changed files with 484 additions and 83 deletions

View File

@ -1,9 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wind/newsgroup_list_view.dart'; import 'package:wind/newsgroup_list_view.dart';
import 'package:wind/nntp_client.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() { void main() {
runApp(MyApp()); runApp(MultiProvider(providers: [
Provider<NNTPClient>(create: ((context) => NNTPClient("localhost:1120"))),
ChangeNotifierProxyProvider<NNTPClient, ThreadListModel>(
create: (context) => ThreadListModel(),
update: (context, client, model) {
model!.client = client;
return model;
}),
], child: MyApp()));
} }
class MyApp extends StatelessWidget { class MyApp extends StatelessWidget {
@ -16,6 +29,7 @@ class MyApp extends StatelessWidget {
primarySwatch: Colors.indigo, primarySwatch: Colors.indigo,
), ),
home: MyHomePage(title: 'Wind'), home: MyHomePage(title: 'Wind'),
routes: {'/thread': (context) => ThreadScreen()},
); );
} }
} }
@ -72,14 +86,15 @@ class _MyHomePageState extends State<MyHomePage> {
Expanded( Expanded(
child: Builder( child: Builder(
builder: (context) => builder: (context) =>
NewsgroupListView(nntpClient))), NewsgroupListView(client: nntpClient))),
], ],
), ),
), ),
const VerticalDivider(thickness: 1, width: 1), const VerticalDivider(thickness: 1, width: 1),
Expanded( Expanded(
child: Center( child: Center(
child: ThreadsListView(), child: Consumer<ThreadListModel>(
builder: ((context, value, child) => ThreadListView())),
)) ))
], ],
), ),
@ -87,67 +102,3 @@ class _MyHomePageState extends State<MyHomePage> {
)); ));
} }
} }
class ThreadsListView extends StatefulWidget {
@override
State<StatefulWidget> createState() => ThreadsListViewState();
}
class ThreadsListViewState extends State<ThreadsListView> {
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),
)
],
),
));
}
}

View File

@ -1,10 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wind/nntp_client.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; final NNTPClient client;
NewsgroupListView(this.client); @override
State<StatefulWidget> createState() => new NewsgroupListViewState(client);
}
class NewsgroupListViewState extends State<NewsgroupListView> {
late NNTPClient client;
int _selectedIndex = -1;
NewsgroupListViewState(this.client);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -13,7 +25,7 @@ class NewsgroupListView extends StatelessWidget {
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
List<GroupInfo> data = snapshot.data!; List<GroupInfo> data = snapshot.data!;
return _newgroupListView(data); return _newsgroupListView(data);
} else if (snapshot.hasError) { } else if (snapshot.hasError) {
return Text("${snapshot.error}"); return Text("${snapshot.error}");
} }
@ -26,7 +38,7 @@ class NewsgroupListView extends StatelessWidget {
return await client.getNewsGroupList(); return await client.getNewsGroupList();
} }
ListView _newgroupListView(List<GroupInfo> data) { Widget _newsgroupListView(List<GroupInfo> data) {
return ListView.builder( return ListView.builder(
itemCount: data.length, itemCount: data.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -34,8 +46,14 @@ class NewsgroupListView extends StatelessWidget {
style: ListTileStyle.drawer, style: ListTileStyle.drawer,
title: Text(data[index].name), title: Text(data[index].name),
subtitle: Text(data[index].description), subtitle: Text(data[index].description),
onTap: () => {}); selected: index == _selectedIndex,
}, onTap: () {
); setState(() => _selectedIndex = index);
var model = context.read<ThreadListModel>();
model
.selectNewsgroup(data[index].name)
.whenComplete(() => null);
});
});
} }
} }

View File

@ -1,10 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:enough_mail/mime_message.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:tuple/tuple.dart';
class NNTPClient { class NNTPClient {
late WebSocketChannel _channel; late WebSocketChannel _channel;
final Queue<_NNTPCommand> commandQueue = new Queue(); final Queue<_NNTPCommand> commandQueue = new Queue();
final List<String> tempBuffer = [];
String? currentGroup;
NNTPClient(String addr) { NNTPClient(String addr) {
_channel = WebSocketChannel.connect( _channel = WebSocketChannel.connect(
@ -12,14 +17,30 @@ class NNTPClient {
); );
_channel.stream.listen((data) { _channel.stream.listen((data) {
if ((data as String).contains("201")) { if ((data as String).startsWith("201")) {
// skip welcome message // skip welcome message
return; return;
} }
var command = commandQueue.removeFirst();
var resp = data.toString(); var resp = data.toString();
var respLines = resp.split("\r\n"); 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]); var respCode = int.parse(respLines[0].split(" ")[0]);
command.responseCompleter.complete(_CommandResponse(respCode, respLines)); command.responseCompleter.complete(_CommandResponse(respCode, respLines));
}); });
@ -76,6 +97,35 @@ class NNTPClient {
return l; return l;
} }
Future<void> selectGroup(String name) async {
await _sendCommand("GROUP", [name]).then((value) => {currentGroup = name});
}
Future<List<Tuple2<int, MimeMessage>>> getNewThreads(
int perPage, int pageNum) async {
if (currentGroup == null) throw new ArgumentError("current group is null");
List<Tuple2<int, MimeMessage>> threads = [];
var newThreadList = await _sendCommand(
"NEWTHREADS", [perPage.toString(), pageNum.toString()]);
newThreadList.lines.removeAt(0);
newThreadList.lines.removeLast(); // remove dot
await Future.forEach<String>(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 { class _NNTPCommand {

View File

@ -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<String, Map<int, List<ThreadItem>>> threads = {};
Future<void> selectNewsgroup(String name) async {
if (currentGroup == name) return;
currentGroup = name;
await client!.selectGroup(name);
threads.putIfAbsent(name, () => {});
notifyListeners();
}
Future<List<ThreadItem>> getNewThreads(
int perPage, int pageNum, bool clearCache) async {
if (currentGroup == "") return [];
List<ThreadItem> 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;
}
}

150
lib/thread_list_view.dart Normal file
View File

@ -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<StatefulWidget> createState() => ThreadListViewState();
}
class ThreadListViewState extends State<ThreadListView> {
List<ThreadItem> _items = [];
int _pageNum = 0;
String _curGroup = "";
@override
Widget build(BuildContext context) {
return Container(
width: 640,
padding: EdgeInsets.only(top: 16),
child: FutureBuilder<List<ThreadItem>>(
future: _fetchThreadList(context),
builder: (context, snapshot) {
if (snapshot.hasData &&
snapshot.connectionState != ConnectionState.waiting) {
List<ThreadItem> 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<List<ThreadItem>> _fetchThreadList(BuildContext context) async {
var model = context.read<ThreadListModel>();
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<Color>(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);
}

17
lib/thread_screen.dart Normal file
View File

@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
class ThreadScreen extends StatefulWidget {
@override
State<StatefulWidget> createState() => ThreadScreenState();
}
class ThreadScreenState extends State<ThreadScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Thread"),
),
);
}
}

View File

@ -1,6 +1,20 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: 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: async:
dependency: transitive dependency: transitive
description: description:
@ -8,6 +22,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.9.0" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -43,6 +64,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
convert:
dependency: transitive
description:
name: convert
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.1"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@ -57,6 +85,41 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.0.4" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -74,6 +137,48 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: matcher:
dependency: transitive dependency: transitive
description: description:
@ -95,6 +200,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.7.0" version: "1.7.0"
nested:
dependency: transitive
description:
name: nested
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -102,6 +214,41 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.8.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@ -149,6 +296,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.4.9" version: "0.4.9"
tuple:
dependency: "direct main"
description:
name: tuple
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.0"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -170,5 +324,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0" version: "2.1.0"
xml:
dependency: transitive
description:
name: xml
url: "https://pub.dartlang.org"
source: hosted
version: "5.4.1"
sdks: sdks:
dart: ">=2.17.0-0 <3.0.0" dart: ">=2.17.0-0 <3.0.0"
flutter: ">=1.16.0"

View File

@ -29,6 +29,9 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.2 cupertino_icons: ^1.0.2
web_socket_channel: ^2.1.0 web_socket_channel: ^2.1.0
provider: ^6.0.2
enough_mail: ^1.3.6
tuple: ^2.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: