Explore page

This commit is contained in:
LittleSheep 2024-03-23 23:05:04 +08:00
parent 8bb9c960c1
commit a3b4706ca2
15 changed files with 540 additions and 284 deletions

View File

@ -43,9 +43,11 @@ class SolarAgent extends StatelessWidget {
initialEntries: [
OverlayEntry(
builder: (context) => SafeArea(
child: Scaffold(
body: child,
bottomNavigationBar: const AgentNavigation(),
child: SafeArea(
child: Scaffold(
body: child,
bottomNavigationBar: const AgentNavigation(),
),
),
),
)

59
lib/models/author.dart Normal file
View File

@ -0,0 +1,59 @@
class Author {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String name;
String nick;
String avatar;
String banner;
String description;
String emailAddress;
int powerLevel;
int externalId;
Author({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.name,
required this.nick,
required this.avatar,
required this.banner,
required this.description,
required this.emailAddress,
required this.powerLevel,
required this.externalId,
});
factory Author.fromJson(Map<String, dynamic> json) => Author(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
name: json["name"],
nick: json["nick"],
avatar: json["avatar"],
banner: json["banner"],
description: json["description"],
emailAddress: json["email_address"],
powerLevel: json["power_level"],
externalId: json["external_id"],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"name": name,
"nick": nick,
"avatar": avatar,
"banner": banner,
"description": description,
"email_address": emailAddress,
"power_level": powerLevel,
"external_id": externalId,
};
}

154
lib/models/feed.dart Normal file
View File

@ -0,0 +1,154 @@
import 'package:solaragent/models/author.dart';
class Feed {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String alias;
String title;
String description;
String content;
String modelType;
int commentCount;
int reactionCount;
int authorId;
int? realmId;
Author author;
List<Attachment>? attachments;
Map<String, dynamic>? reactionList;
Feed({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.alias,
required this.title,
required this.description,
required this.content,
required this.modelType,
required this.commentCount,
required this.reactionCount,
required this.authorId,
this.realmId,
required this.author,
this.attachments,
this.reactionList,
});
factory Feed.fromJson(Map<String, dynamic> json) => Feed(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
alias: json["alias"],
title: json["title"],
description: json["description"],
content: json["content"],
modelType: json["model_type"],
commentCount: json["comment_count"],
reactionCount: json["reaction_count"],
authorId: json["author_id"],
realmId: json["realm_id"],
author: Author.fromJson(json["author"]),
attachments: json["attachments"] != null
? List<Attachment>.from(
json["attachments"].map((x) => Attachment.fromJson(x)))
: List.empty(),
reactionList: json["reaction_list"],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"alias": alias,
"title": title,
"description": description,
"content": content,
"model_type": modelType,
"comment_count": commentCount,
"reaction_count": reactionCount,
"author_id": authorId,
"realm_id": realmId,
"author": author.toJson(),
"attachments": attachments == null
? List.empty()
: List<dynamic>.from(attachments!.map((x) => x.toJson())),
"reaction_list": reactionList,
};
}
class Attachment {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String fileId;
int filesize;
String filename;
String mimetype;
int type;
String externalUrl;
Author author;
int? articleId;
int? momentId;
int? commentId;
int? authorId;
Attachment({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.fileId,
required this.filesize,
required this.filename,
required this.mimetype,
required this.type,
required this.externalUrl,
required this.author,
this.articleId,
this.momentId,
this.commentId,
this.authorId,
});
factory Attachment.fromJson(Map<String, dynamic> json) => Attachment(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
fileId: json["file_id"],
filesize: json["filesize"],
filename: json["filename"],
mimetype: json["mimetype"],
type: json["type"],
externalUrl: json["external_url"],
author: Author.fromJson(json["author"]),
articleId: json["article_id"],
momentId: json["moment_id"],
commentId: json["comment_id"],
authorId: json["author_id"],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"file_id": fileId,
"filesize": filesize,
"filename": filename,
"mimetype": mimetype,
"type": type,
"external_url": externalUrl,
"author": author.toJson(),
"article_id": articleId,
"moment_id": momentId,
"comment_id": commentId,
"author_id": authorId,
};
}

View File

@ -0,0 +1,17 @@
class PaginationResult {
int count;
List<dynamic>? data;
PaginationResult({
required this.count,
this.data,
});
factory PaginationResult.fromJson(Map<String, dynamic> json) =>
PaginationResult(count: json["count"], data: json["data"]);
Map<String, dynamic> toJson() => {
"count": count,
"data": data,
};
}

View File

@ -1,13 +1,13 @@
import 'package:go_router/go_router.dart';
import 'package:solaragent/screens/account.dart';
import 'package:solaragent/screens/dashboard.dart';
import 'package:solaragent/screens/explore.dart';
import 'package:solaragent/screens/notifications.dart';
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const DashboardScreen(),
builder: (context, state) => const ExploreScreen(),
),
GoRoute(
path: '/notifications',

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:solaragent/auth.dart';
import 'package:solaragent/screens/about.dart';
import 'package:solaragent/widgets/name_card.dart';
@ -27,72 +26,69 @@ class _AccountScreenState extends State<AccountScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 20),
child: Column(children: [
Padding(
padding: const EdgeInsets.only(top: 20),
child: NameCard(
onLogin: () async {
await authClient.signin(context);
var authorized = await authClient.isAuthorized();
setState(() {
isAuthorized = authorized;
});
},
),
body: Padding(
padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 20),
child: Column(children: [
Padding(
padding: const EdgeInsets.only(top: 20),
child: NameCard(
onLogin: () async {
await authClient.signin(context);
var authorized = await authClient.isAuthorized();
setState(() {
isAuthorized = authorized;
});
},
),
Padding(
padding: const EdgeInsets.only(top: 5),
child: Wrap(
spacing: 5,
children: [
FutureBuilder(
future: authClient.isAuthorized(),
builder:
(BuildContext context, AsyncSnapshot<bool> snapshot) {
return (snapshot.hasData && snapshot.data == true)
? InkWell(
borderRadius:
const BorderRadius.all(Radius.circular(40)),
splashColor: Colors.indigo.withAlpha(30),
onTap: () async {
authClient.signoff();
var authorized =
await authClient.isAuthorized();
setState(() {
isAuthorized = authorized;
});
},
child: const ListTile(
leading: Icon(Icons.logout),
title: Text('Logout'),
),
)
: Container();
},
),
Padding(
padding: const EdgeInsets.only(top: 5),
child: Wrap(
spacing: 5,
children: [
FutureBuilder(
future: authClient.isAuthorized(),
builder:
(BuildContext context, AsyncSnapshot<bool> snapshot) {
return (snapshot.hasData && snapshot.data == true)
? InkWell(
borderRadius:
const BorderRadius.all(Radius.circular(40)),
splashColor: Colors.indigo.withAlpha(30),
onTap: () async {
authClient.signoff();
var authorized = await authClient.isAuthorized();
setState(() {
isAuthorized = authorized;
});
},
child: const ListTile(
leading: Icon(Icons.logout),
title: Text('Logout'),
),
)
: Container();
},
),
InkWell(
borderRadius: const BorderRadius.all(Radius.circular(40)),
splashColor: Colors.indigo.withAlpha(30),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AboutScreen(),
));
},
child: const ListTile(
leading: Icon(Icons.info_outline),
title: Text('About'),
),
InkWell(
borderRadius: const BorderRadius.all(Radius.circular(40)),
splashColor: Colors.indigo.withAlpha(30),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const AboutScreen(),
));
},
child: const ListTile(
leading: Icon(Icons.info_outline),
title: Text('About'),
),
),
],
),
),
],
),
]),
),
),
]),
),
);
}

View File

@ -1,36 +0,0 @@
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class ApplicationScreen extends StatelessWidget {
final Uri link;
final String title;
const ApplicationScreen({super.key, required this.link, required this.title});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
bottom: false,
child: Stack(
children: [
WebViewWidget(
controller: WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.white)
..setNavigationDelegate(NavigationDelegate(
onPageStarted: (_) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("Swipe from left to back to dashboard."),
));
}
))
..loadRequest(link)
..clearCache(),
),
],
),
),
);
}
}

View File

@ -1,93 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:solaragent/screens/application.dart';
import 'package:http/http.dart' as http;
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
final client = http.Client();
final directoryEndpoint =
Uri.parse('https://id.solsynth.dev/.well-known');
List<dynamic> directory = List.empty();
@override
void initState() {
super.initState();
_pullDirectory();
}
Future<void> _pullDirectory() async {
var response = await client.get(directoryEndpoint);
if (response.statusCode == 200) {
setState(() {
directory = jsonDecode(utf8.decode(response.bodyBytes))["directory"];
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.only(left: 20, right: 20, top: 30),
child: GridView.count(
crossAxisCount: 2,
children: directory.map((element) {
return Card(
child: InkWell(
onTap: () async {
var link = element["link"];
Navigator.of(context, rootNavigator: true)
.push(MaterialPageRoute(
builder: (context) => ApplicationScreen(
link: Uri.parse(link),
title: element["name"],
),
));
},
splashColor: Colors.indigo.withAlpha(30),
child: Padding(
padding: const EdgeInsets.all(10),
child: Wrap(
spacing: 8.0,
children: [
Padding(
padding: const EdgeInsets.only(left: 6, top: 2),
child: Card(
shape: const CircleBorder(),
elevation: 0,
color: Colors.indigo.withAlpha(70),
child: const Padding(
padding: EdgeInsets.all(16),
child: Icon(Icons.apps),
),
),
),
ListTile(
title: Text(element['name']),
subtitle: Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(element['description'], style: Theme.of(context).textTheme.bodySmall),
),
),
],
),
),
),
);
}).toList(),
),
),
),
);
}
}

78
lib/screens/explore.dart Normal file
View File

@ -0,0 +1,78 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solaragent/models/feed.dart';
import 'package:solaragent/models/pagination.dart';
import 'package:http/http.dart' as http;
import 'package:solaragent/widgets/feed.dart';
class ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key});
@override
State<ExploreScreen> createState() => _ExploreScreenState();
}
class _ExploreScreenState extends State<ExploreScreen> {
static const pageSize = 5;
final client = http.Client();
final PagingController<int, Feed> paginationController =
PagingController(firstPageKey: 0);
List<Feed> feed = List.empty();
@override
void initState() {
super.initState();
paginationController.addPageRequestListener((pageKey) {
pullFeed(pageKey);
});
}
Future<void> pullFeed(int pageKey) async {
var offset = pageKey;
var take = pageSize;
var uri =
Uri.parse('https://co.solsynth.dev/api/feed?take=$take&offset=$offset');
var res = await client.get(uri);
if (res.statusCode == 200) {
final result =
PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
final isLastPage = (result.data?.length ?? 0) < pageSize;
if (isLastPage) {
paginationController.appendLastPage(feed);
} else {
final feed = result.data!.map((x) => Feed.fromJson(x)).toList();
final nextPageKey = pageKey + feed.length;
paginationController.appendPage(feed, nextPageKey);
}
} else {
paginationController.error = utf8.decode(res.bodyBytes);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: PagedListView<int, Feed>(
pagingController: paginationController,
builderDelegate: PagedChildBuilderDelegate<Feed>(
itemBuilder: (context, item, index) => FeedItem(
item: item,
),
),
),
);
}
@override
void dispose() {
paginationController.dispose();
super.dispose();
}
}

View File

@ -46,94 +46,92 @@ class _NotificationScreenState extends State<NotificationScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: RefreshIndicator(
onRefresh: pullNotifications,
child: CustomScrollView(
slivers: [
// Title
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10, top: 20),
child: ListTile(
title: Text(
'Notifications',
style: Theme.of(context).textTheme.headlineSmall,
),
body: RefreshIndicator(
onRefresh: pullNotifications,
child: CustomScrollView(
slivers: [
// Title
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10, top: 20),
child: ListTile(
title: Text(
'Notifications',
style: Theme.of(context).textTheme.headlineSmall,
),
),
),
// Content
notifications.isEmpty
? SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
color: Colors.grey[300],
child: const ListTile(
leading: Icon(Icons.check),
title: Text('You\'re done!'),
subtitle: Text(
'There are no notifications unread for you.',
),
),
// Content
notifications.isEmpty
? SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
color: Colors.grey[300],
child: const ListTile(
leading: Icon(Icons.check),
title: Text('You\'re done!'),
subtitle: Text(
'There are no notifications unread for you.',
),
),
)
: SliverList.builder(
itemCount: notifications.length,
itemBuilder: (BuildContext context, int index) {
var element = notifications[index];
return Dismissible(
key: Key('notification-$index'),
onDismissed: (direction) {
var subject = element["subject"];
markAsRead(element).then((value) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: RichText(
text: TextSpan(children: [
TextSpan(
text: subject,
style: const TextStyle(
fontWeight: FontWeight.bold),
),
const TextSpan(
text: " is marked as read",
)
]),
),
),
);
});
setState(() {
notifications.removeAt(index);
});
},
background: Container(
color: Colors.green,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: ListTile(
title: Text(element["subject"]),
subtitle: Text(element["content"]),
),
),
);
},
),
// Tips
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 10),
child: Text(
"Pull to refresh, swipe to dismiss",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
)
: SliverList.builder(
itemCount: notifications.length,
itemBuilder: (BuildContext context, int index) {
var element = notifications[index];
return Dismissible(
key: Key('notification-$index'),
onDismissed: (direction) {
var subject = element["subject"];
markAsRead(element).then((value) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: RichText(
text: TextSpan(children: [
TextSpan(
text: subject,
style: const TextStyle(
fontWeight: FontWeight.bold),
),
const TextSpan(
text: " is marked as read",
)
]),
),
),
);
});
setState(() {
notifications.removeAt(index);
});
},
background: Container(
color: Colors.green,
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: ListTile(
title: Text(element["subject"]),
subtitle: Text(element["content"]),
),
),
);
},
),
// Tips
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.only(top: 12),
child: Text(
"Pull to refresh, swipe to dismiss",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
),
],
),
),
);

43
lib/widgets/feed.dart Normal file
View File

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:solaragent/models/feed.dart';
class FeedItem extends StatelessWidget {
final Feed item;
const FeedItem({super.key, required this.item});
String getDescription(String desc) =>
desc.isEmpty ? "No description yet." : desc;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Column(
children: [
Container(
color: Colors.grey[50],
child: ListTile(
title: Text(item.author.name),
leading: CircleAvatar(
backgroundImage: NetworkImage(item.author.avatar),
),
subtitle: Text(
getDescription(item.author.description),
overflow: TextOverflow.ellipsis,
maxLines: 1,
softWrap: false,
),
),
),
Markdown(
data: item.content,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
),
],
),
);
}
}

View File

@ -1,9 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../auth.dart';
import 'package:solaragent/auth.dart';
class NameCard extends StatelessWidget {
const NameCard({super.key, this.onLogin, this.onCheck});

View File

@ -5,7 +5,7 @@ class AgentNavigation extends StatefulWidget {
const AgentNavigation({super.key});
static const List<(String, NavigationDestination)> destinations = [
('/', NavigationDestination(icon: Icon(Icons.home), label: 'Home')),
('/', NavigationDestination(icon: Icon(Icons.explore), label: 'Explore')),
('/notifications', NavigationDestination(icon: Icon(Icons.notifications), label: 'Notifications')),
('/account', NavigationDestination(icon: Icon(Icons.account_circle), label: 'Account')),
];

View File

@ -142,6 +142,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.3"
flutter_markdown:
dependency: "direct main"
description:
name: flutter_markdown
sha256: "87e11b9df25a42e2db315b8b7a51fae8e66f57a4b2f50ec4b822d0fa155e6b52"
url: "https://pub.dev"
source: hosted
version: "0.6.22"
flutter_secure_storage:
dependency: "direct main"
description:
@ -190,6 +198,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_staggered_grid_view:
dependency: transitive
description:
name: flutter_staggered_grid_view
sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_test:
dependency: "direct dev"
description: flutter
@ -232,6 +248,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.7"
infinite_scroll_pagination:
dependency: "direct main"
description:
name: infinite_scroll_pagination
sha256: b68bce20752fcf36c7739e60de4175494f74e99e9a69b4dd2fe3a1dd07a7f16a
url: "https://pub.dev"
source: hosted
version: "4.0.0"
js:
dependency: transitive
description:
@ -288,6 +312,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051
url: "https://pub.dev"
source: hosted
version: "7.2.2"
matcher:
dependency: transitive
description:
@ -485,6 +517,14 @@ packages:
description: flutter
source: sdk
version: "0.0.99"
sliver_tools:
dependency: transitive
description:
name: sliver_tools
sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6
url: "https://pub.dev"
source: hosted
version: "0.2.12"
source_span:
dependency: transitive
description:
@ -695,4 +735,4 @@ packages:
version: "3.1.2"
sdks:
dart: ">=3.3.0 <4.0.0"
flutter: ">=3.16.6"
flutter: ">=3.19.0"

View File

@ -43,6 +43,8 @@ dependencies:
package_info_plus: ^5.0.1
url_launcher: ^6.2.4
shared_preferences: ^2.2.2
flutter_markdown: ^0.6.22
infinite_scroll_pagination: ^4.0.0
dev_dependencies:
flutter_test: