Dashboard explore

This commit is contained in:
2024-04-13 19:47:31 +08:00
parent dd1354d99c
commit 5c32f7856f
26 changed files with 1422 additions and 21 deletions

View File

@ -1,4 +1,4 @@
{
"solian": "HAIYAA",
"solian": "索链",
"explore": "探索"
}

View File

@ -2,10 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/layout_provider.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/timeago.dart';
import 'package:solian/widgets/wrapper.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() {
initTimeAgo();
runApp(const SolianApp());
}
@ -28,8 +31,10 @@ class SolianApp extends StatelessWidget {
return Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return Provider(
create: (_) => LayoutConfig(context),
return MultiProvider(
providers: [
Provider(create: (_) => LayoutConfig(context))
],
child: LayoutWrapper(
child: child,
),

59
lib/models/author.dart Executable 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,
};
}

79
lib/models/notification.dart Executable file
View File

@ -0,0 +1,79 @@
class Notification {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String subject;
String content;
List<Link>? links;
bool isImportant;
DateTime? readAt;
int senderId;
int recipientId;
Notification({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.subject,
required this.content,
this.links,
required this.isImportant,
this.readAt,
required this.senderId,
required this.recipientId,
});
factory Notification.fromJson(Map<String, dynamic> json) => Notification(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
subject: json["subject"],
content: json["content"],
links: json["links"] != null
? List<Link>.from(json["links"].map((x) => Link.fromJson(x)))
: List.empty(),
isImportant: json["is_important"],
readAt: json["read_at"],
senderId: json["sender_id"],
recipientId: json["recipient_id"],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"subject": subject,
"content": content,
"links": links != null
? List<dynamic>.from(links!.map((x) => x.toJson()))
: List.empty(),
"is_important": isImportant,
"read_at": readAt,
"sender_id": senderId,
"recipient_id": recipientId,
};
}
class Link {
String label;
String url;
Link({
required this.label,
required this.url,
});
factory Link.fromJson(Map<String, dynamic> json) => Link(
label: json["label"],
url: json["url"],
);
Map<String, dynamic> toJson() => {
"label": label,
"url": url,
};
}

17
lib/models/pagination.dart Executable file
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,
};
}

154
lib/models/post.dart Executable file
View File

@ -0,0 +1,154 @@
import 'package:solian/models/author.dart';
class Post {
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;
Post({
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 Post.fromJson(Map<String, dynamic> json) => Post(
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

@ -1,7 +1,15 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/utils/service_url.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/providers/layout_provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:http/http.dart' as http;
import 'package:solian/widgets/posts/item.dart';
class ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key});
@ -11,19 +19,67 @@ class ExploreScreen extends StatefulWidget {
}
class _ExploreScreenState extends State<ExploreScreen> {
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
final http.Client _client = http.Client();
Future<void> fetchFeed(int pageKey) async {
final offset = pageKey;
const take = 5;
var uri =
getRequestUri('interactive', '/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 items =
result.data?.map((x) => Post.fromJson(x)).toList() ?? List.empty();
final isLastPage = (result.count - pageKey) < take;
if (isLastPage || result.data == null) {
_pagingController.appendLastPage(items);
} else {
final nextPageKey = pageKey + items.length;
_pagingController.appendPage(items, nextPageKey);
}
} else {
_pagingController.error = utf8.decode(res.bodyBytes);
}
}
@override
void initState() {
Future.delayed(Duration.zero, () {
context.read<LayoutConfig>().title = AppLocalizations.of(context)!.explore;
// Wait for the context
context.read<LayoutConfig>().title =
AppLocalizations.of(context)!.explore;
});
super.initState();
_pagingController.addPageRequestListener((pageKey) => fetchFeed(pageKey));
}
@override
Widget build(BuildContext context) {
return const Center(
child: Text("Woah"),
return RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 720),
child: PagedListView<int, Post>.separated(
pagingController: _pagingController,
separatorBuilder: (context, index) => const Divider(thickness: 0.3),
builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) => PostItem(item: item),
),
),
),
),
);
}
}

View File

@ -0,0 +1,10 @@
const serviceUrls = {
'passport': 'https://id.solsynth.dev',
'interactive': 'https://co.solsynth.dev',
'messaging': 'https://im.solsynth.dev'
};
Uri getRequestUri(String service, String path) {
final baseUrl = serviceUrls[service];
return Uri.parse(baseUrl! + path);
}

5
lib/utils/timeago.dart Normal file
View File

@ -0,0 +1,5 @@
import 'package:timeago/timeago.dart' as timeago;
void initTimeAgo() {
timeago.setLocaleMessages('zh', timeago.ZhMessages());
}

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class AttachmentScreen extends StatelessWidget {
final String tag;
final String url;
const AttachmentScreen({super.key, required this.tag, required this.url});
@override
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(
child: Center(
child: SizedBox(
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
child: InteractiveViewer(
boundaryMargin: const EdgeInsets.all(128),
minScale: 0.1,
maxScale: 16.0,
child: Hero(
tag: tag,
child: Image.network(url, fit: BoxFit.contain),
),
),
),
),
onTap: () {
Navigator.pop(context);
},
),
);
}
}

View File

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:solian/models/post.dart';
import 'package:markdown/markdown.dart' as markdown;
import 'package:url_launcher/url_launcher_string.dart';
class ArticleContent extends StatelessWidget {
final Post item;
final bool brief;
const ArticleContent({super.key, required this.item, required this.brief});
@override
Widget build(BuildContext context) {
return brief
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
item.description,
style: Theme.of(context).textTheme.bodyMedium,
)
],
),
)
: Column(
children: [
ListTile(
title: Text(item.title),
subtitle: Text(item.description),
),
const Divider(color: Color(0xffefefef)),
Markdown(
selectable: !brief,
data: item.content,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
extensionSet: markdown.ExtensionSet(
markdown.ExtensionSet.gitHubFlavored.blockSyntaxes,
markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes,
),
onTapLink: (text, href, title) async {
if (href == null) return;
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
),
],
);
}
}

View File

@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:chewie/chewie.dart';
import 'package:solian/models/post.dart';
import 'package:solian/utils/service_url.dart';
import 'package:flutter_carousel_widget/flutter_carousel_widget.dart';
import 'package:solian/widgets/posts/attachment_screen.dart';
import 'package:video_player/video_player.dart';
class AttachmentItem extends StatefulWidget {
final Attachment item;
const AttachmentItem({super.key, required this.item});
@override
State<AttachmentItem> createState() => _AttachmentItemState();
}
class _AttachmentItemState extends State<AttachmentItem> {
String getTag() => 'attachment-${widget.item.fileId}';
Uri getFileUri() =>
getRequestUri('interactive', '/api/attachments/o/${widget.item.fileId}');
VideoPlayerController? _vpController;
ChewieController? _chewieController;
@override
Widget build(BuildContext context) {
const borderRadius = Radius.circular(16);
Widget content;
if (widget.item.type == 1) {
content = GestureDetector(
child: ClipRRect(
borderRadius: const BorderRadius.all(borderRadius),
child: Hero(
tag: getTag(),
child: Image.network(
getFileUri().toString(),
fit: BoxFit.cover,
),
),
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) {
return AttachmentScreen(
tag: getTag(),
url: getFileUri().toString(),
);
}),
);
},
);
} else {
_vpController = VideoPlayerController.networkUrl(getFileUri());
_chewieController = ChewieController(
videoPlayerController: _vpController!,
);
content = FutureBuilder(
future: () async {
await _vpController?.initialize();
return true;
}(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ClipRRect(
borderRadius: const BorderRadius.all(borderRadius),
child: Chewie(
controller: _chewieController!,
),
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
}
return Container(
width: MediaQuery.of(context).size.width,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(borderRadius),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 0.9,
),
),
child: content,
);
}
@override
void dispose() {
_vpController?.dispose();
_chewieController?.dispose();
super.dispose();
}
}
class AttachmentList extends StatelessWidget {
final List<Attachment> items;
const AttachmentList({super.key, required this.items});
@override
Widget build(BuildContext context) {
return FlutterCarousel(
options: CarouselOptions(
aspectRatio: 16 / 9,
showIndicator: true,
slideIndicator: const CircularSlideIndicator(),
),
items: items.map((item) {
return Builder(
builder: (BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: AttachmentItem(item: item),
);
},
);
}).toList(),
);
}
}

View File

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:solian/models/post.dart';
class MomentContent extends StatelessWidget {
final Post item;
final bool brief;
const MomentContent({super.key, required this.brief, required this.item});
@override
Widget build(BuildContext context) {
return Markdown(
selectable: !brief,
data: item.content,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 12),
);
}
}

View File

@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:solian/models/post.dart';
import 'package:solian/widgets/posts/content/article.dart';
import 'package:solian/widgets/posts/content/attachment.dart';
import 'package:solian/widgets/posts/content/moment.dart';
import 'package:timeago/timeago.dart' as timeago;
class PostItem extends StatefulWidget {
final Post item;
const PostItem({super.key, required this.item});
@override
State<PostItem> createState() => _PostItemState();
}
class _PostItemState extends State<PostItem> {
Map<String, dynamic>? reactionList;
Widget renderContent() {
switch (widget.item.modelType) {
case "article":
return ArticleContent(item: widget.item, brief: true);
default:
return MomentContent(item: widget.item, brief: true);
}
}
Widget renderAttachments() {
if(widget.item.attachments != null && widget.item.attachments!.isNotEmpty) {
return AttachmentList(items: widget.item.attachments!);
} else {
return Container();
}
}
@override
Widget build(BuildContext context) {
const borderRadius = Radius.circular(16);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundImage: NetworkImage(widget.item.author.avatar),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Text(
widget.item.author.nick,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 4),
Text(
timeago.format(widget.item.createdAt)
)
],
),
),
renderContent(),
],
),
),
],
),
Padding(
padding: const EdgeInsets.all(8.0),
child: renderAttachments(),
),
],
),
);
}
}

View File

@ -14,20 +14,8 @@ class LayoutWrapper extends StatelessWidget {
return Scaffold(
drawer: const SolianNavigationDrawer(),
body: NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return <Widget>[
SliverAppBar(
title: Text(cfg.title),
elevation: 10.0,
expandedHeight: 50,
floating: true,
snap: true,
),
];
},
body: child ?? Container(),
),
appBar: AppBar(title: Text(cfg.title)),
body: child ?? Container(),
);
}
}