Compare commits
42 Commits
Author | SHA1 | Date | |
---|---|---|---|
c5258cb9ca | |||
47c535910d | |||
66f2f33394 | |||
f5fbe1f483 | |||
fcf4dc7a2d | |||
43b7059957 | |||
11c913af60 | |||
db8f0d63e1 | |||
4036a79995 | |||
859bbd09e0 | |||
60033fdef3 | |||
9c3d181deb | |||
9e6829bd5a | |||
f50461a7f7 | |||
147879e4d8 | |||
f353c05cb5 | |||
ac60043ca7 | |||
8d79274b0c | |||
ad4e4071fa | |||
c59f77c877 | |||
16047a7d57 | |||
fdc68fc5e1 | |||
bbee825cf4 | |||
2673c11046 | |||
3ac6822ab6 | |||
7a5fd2e468 | |||
e1ddd22e4e | |||
22b2ae32e9 | |||
9d5c452eae | |||
0fdb1e4ead | |||
724bd6592e | |||
2d347e0d41 | |||
de39799301 | |||
4b921602a2 | |||
6cde218393 | |||
c896185af0 | |||
4cbeafd447 | |||
91a32e6736 | |||
befc647b03 | |||
16b2e3a0c7 | |||
0cc842c030 | |||
fb370a484d |
@ -4,3 +4,4 @@ android.enableJetifier=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=false
|
||||
android.nonFinalResIds=false
|
||||
kotlin.jvm.target.validation.mode = IGNORE
|
||||
|
@ -22,9 +22,9 @@
|
||||
"explore": "Explore",
|
||||
"posts": "Posts",
|
||||
"unlink": "Unlink",
|
||||
"feedSearch": "Search Feed",
|
||||
"feedSearchWithTag": "Searching with tag #@key",
|
||||
"feedSearchWithCategory": "Searching in category @category",
|
||||
"postSearch": "Search Post",
|
||||
"postSearchWithTag": "Searching with tag #@key",
|
||||
"postSearchWithCategory": "Searching in category @category",
|
||||
"feedUnreadCount": "@count posts you may missed",
|
||||
"messages": "Messages",
|
||||
"messagesUnreadCount": "@count messages unread",
|
||||
@ -98,6 +98,8 @@
|
||||
"accountFriendBlocked": "Friend blocklist",
|
||||
"accountFriendListHint": "Swipe left to decline, right to approve",
|
||||
"accountFriendRequestSent": "Friend request sent, waiting for processing...",
|
||||
"accountBlocked": "Account has been blocked",
|
||||
"accountUnblocked": "Account has been unblocked",
|
||||
"accountSuspended": "Account was suspended",
|
||||
"accountSuspendedAt": "Account was suspended since @date",
|
||||
"aspectRatio": "Aspect Ratio",
|
||||
@ -428,9 +430,52 @@
|
||||
"preferencesApplied": "Preferences has been applied.",
|
||||
"save": "Save",
|
||||
"updateAvailable": "Update available",
|
||||
"updateAvailableDesc": "There is an update available (@version). Do you want to download and install it now? You can still use the app normally while waiting for the download to complete.",
|
||||
"updateAvailableDesc": "There is an update available (@from to @to). Do you want to download and install it now? You can still use the app normally while waiting for the download to complete.",
|
||||
"update": "Update",
|
||||
"updateCheckStrictly": "Strict mode",
|
||||
"updateCheckStrictlyDesc": "If enabled, the app will ask for updating once the local version is different from remote one.",
|
||||
"updateMayAvailable": "App version @version is available, you can update from app store or our website."
|
||||
"updateMayAvailable": "App version @version is available, you can update from app store or our website.",
|
||||
"updateNow": "Update now",
|
||||
"termAccept": "I've read and agree to Solar Network's Terms",
|
||||
"termAcceptDesc": "Including but not limited to \"User Agreement\" and \"Privacy Policy\"",
|
||||
"termAcceptLink": "View terms",
|
||||
"termAcceptNextWithAgree": "By clicking the \"Next\", it means you agree to our terms and its updates. You should already agreed with them while you sign up.",
|
||||
"termRelated": "Related Terms",
|
||||
"appDetails": "App Details",
|
||||
"projectWebsite": "Project Website",
|
||||
"iAmNotRobot": "I'm not a Robot",
|
||||
"report": "Report",
|
||||
"reportAbuse": "Report abuse",
|
||||
"reportAbuseDesc": "Report any violation of service terms",
|
||||
"reportAbuseResource": "Resource identifier",
|
||||
"reportAbuseReason": "Report reason",
|
||||
"reportSubmitted": "Report submitted, thank you for your contribution. We will send a notification about the result of the report within 24 hours for you.",
|
||||
"accountDeletion": "Request account deletion",
|
||||
"accountDeletionDesc": "Delete the current account and all its data. Note that this action is irreversible!",
|
||||
"accountDeletionConfirm": "Confirm request account deletion",
|
||||
"accountDeletionConfirmDesc": "Are you sure to delete account @account? You will receive a confirmation email with a link to confirm the deletion of the account within 24 hours. Note that this action is irreversible, and all data associated with the account will be deleted, and you should be careful about it.",
|
||||
"accountDeletionRequested": "Account deletion requested, check your inbox to confirm the request.",
|
||||
"slideToConfirm": "Slide to confirm",
|
||||
"serviceStatus": "Status of Service",
|
||||
"firstBootTime": "First boot at @time",
|
||||
"rateTheApp": "Rate the app",
|
||||
"rateTheAppDesc": "Rate Solar Network on the App Store to let us serve you better!",
|
||||
"friendAdd": "Add as friend",
|
||||
"blockUser": "Block user",
|
||||
"unblockUser": "Unblock user",
|
||||
"learnMoreAboutPerson": "Learn more about that person",
|
||||
"global": "Global",
|
||||
"all": "All",
|
||||
"unablePreview": "Unable to preview",
|
||||
"dashboardNav": "Dash",
|
||||
"accountNav": "You",
|
||||
"performance": "Performance",
|
||||
"animatedMessageList": "Non-animated message list",
|
||||
"animatedMessageListDesc": "Remove animation effects in message list, to reduce cause lag",
|
||||
"theme": "Theme",
|
||||
"globalTheme": "Global theme",
|
||||
"agedTheme": "Old school style theme",
|
||||
"agedThemeDesc": "Downgrade the global theme to Material Design 2. Unexpected issues may occur. For experimental use only.",
|
||||
"appBackgroundImage": "Global background image",
|
||||
"appBackgroundImageDesc": "The global background image will be displayed on all pages"
|
||||
}
|
||||
|
@ -32,9 +32,9 @@
|
||||
"dashboard": "仪表盘",
|
||||
"today": "今日",
|
||||
"yesterday": "昨日",
|
||||
"feedSearch": "搜索资讯",
|
||||
"feedSearchWithTag": "检索带有 #@key 标签的资讯",
|
||||
"feedSearchWithCategory": "检索位于分类 @category 的资讯",
|
||||
"postSearch": "搜索帖子",
|
||||
"postSearchWithTag": "检索带有 #@key 标签的资讯",
|
||||
"postSearchWithCategory": "检索位于分类 @category 的资讯",
|
||||
"feedUnreadCount": "@count 条你可能错过的帖子",
|
||||
"messages": "消息",
|
||||
"messagesUnreadCount": "@count 条未读的消息",
|
||||
@ -98,6 +98,8 @@
|
||||
"accountFriendBlocked": "好友黑名单",
|
||||
"accountFriendListHint": "左滑来拒绝,右滑来接受",
|
||||
"accountFriendRequestSent": "好友请求已发送,等待处理对方中……",
|
||||
"accountBlocked": "已屏蔽账号",
|
||||
"accountUnblocked": "已解除屏蔽账号",
|
||||
"accountSuspended": "帐号被停用",
|
||||
"accountSuspendedAt": "该帐号自 @date 起被停用",
|
||||
"aspectRatio": "纵横比",
|
||||
@ -264,7 +266,7 @@
|
||||
"channelMembersAddHint": "到 @channel",
|
||||
"channelType": "频道类型",
|
||||
"channelTypeCommon": "普通频道",
|
||||
"channelTypeDirect": "私信聊天",
|
||||
"channelTypeDirect": "私信",
|
||||
"channelAdjust": "调整频道",
|
||||
"channelDetail": "频道详情",
|
||||
"channelSettings": "频道设置",
|
||||
@ -428,5 +430,48 @@
|
||||
"update": "更新",
|
||||
"updateCheckStrictly": "严格模式",
|
||||
"updateCheckStrictlyDesc": "如果启用,应用程序将会在本地版本与远程版本不同时询问更新,而不会检查版本号大小。",
|
||||
"updateMayAvailable": "版本 @version 现已可用,你可以前往应用商店或是我们的官网下载更新。"
|
||||
"updateNow": "立即更新",
|
||||
"updateMayAvailable": "版本 @version 现已可用,你可以前往应用商店或是我们的官网下载更新。",
|
||||
"termAccept": "我已阅读并同意 Solar Network 各项条款",
|
||||
"termAcceptDesc": "包括但不限于《用户守则》和《隐私政策》",
|
||||
"termAcceptLink": "浏览条款",
|
||||
"termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。你应该在注册时已经同意过了。",
|
||||
"termRelated": "相关条款",
|
||||
"projectWebsite": "项目网站",
|
||||
"appDetails": "应用详情",
|
||||
"iAmNotRobot": "我不是机器人",
|
||||
"report": "举报",
|
||||
"reportAbuse": "举报滥用",
|
||||
"reportAbuseDesc": "举报任何违反服务条款的行为",
|
||||
"reportAbuseResource": "举报的资源",
|
||||
"reportAbuseReason": "举报的原因",
|
||||
"reportSubmitted": "举报已提交,感谢你的贡献。我们将通过通知在 24 小时内通知该举报的处理结果。",
|
||||
"accountDeletion": "请求删除账号",
|
||||
"accountDeletionDesc": "删除目前登陆的账号,及其所有的数据。注意,该操作不可撤销!",
|
||||
"accountDeletionConfirm": "确认账号删除请求",
|
||||
"accountDeletionConfirmDesc": "你确定要删除账号 @account 吗?你将会在其绑定的主要邮件地址收到一封包含着确认删除账号连接的邮件,在二十四小时内使用该连接即可完成删除账号。注意,本操作不可撤销,并且账号创建或关联的所有数据都将被删除,请三思而后行。",
|
||||
"accountDeletionRequested": "已请求删除账号,检查你的收件箱来确认请求。",
|
||||
"slideToConfirm": "滑动来确认",
|
||||
"serviceStatus": "服务状态",
|
||||
"firstBootTime": "首次启动于 @time",
|
||||
"rateTheApp": "给应用评分",
|
||||
"rateTheAppDesc": "在 App Store 上给 Solar Network 评分,让我们更好地为您服务吧!",
|
||||
"friendAdd": "添加好友",
|
||||
"blockUser": "屏蔽用户",
|
||||
"unblockUser": "解除屏蔽用户",
|
||||
"learnMoreAboutPerson": "了解关于 TA 的更多",
|
||||
"global": "全局",
|
||||
"all": "全部",
|
||||
"unablePreview": "无法预览",
|
||||
"dashboardNav": "仪表盘",
|
||||
"accountNav": "您",
|
||||
"performance": "性能",
|
||||
"animatedMessageList": "无动画消息列表",
|
||||
"animatedMessageListDesc": "在消息列表中禁用动画效果",
|
||||
"theme": "主题",
|
||||
"globalTheme": "全局应用主题",
|
||||
"agedTheme": "过时主题",
|
||||
"agedThemeDesc": "将全局主题降级为 Material Design 2,可能发生意料之外的问题,仅供实验使用",
|
||||
"appBackgroundImage": "全局背景图片",
|
||||
"appBackgroundImageDesc": "全局背景图片将会在所有页面中展示"
|
||||
}
|
||||
|
@ -227,7 +227,9 @@ PODS:
|
||||
- TOCropViewController (~> 2.7.4)
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- livekit_client (2.2.5):
|
||||
- in_app_review (0.2.0):
|
||||
- Flutter
|
||||
- livekit_client (2.2.6):
|
||||
- Flutter
|
||||
- WebRTC-SDK (= 125.6422.04)
|
||||
- media_kit_libs_ios_video (1.0.4):
|
||||
@ -318,6 +320,7 @@ DEPENDENCIES:
|
||||
- gal (from `.symlinks/plugins/gal/darwin`)
|
||||
- image_cropper (from `.symlinks/plugins/image_cropper/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- livekit_client (from `.symlinks/plugins/livekit_client/ios`)
|
||||
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
|
||||
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
|
||||
@ -406,6 +409,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/image_cropper/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
in_app_review:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
livekit_client:
|
||||
:path: ".symlinks/plugins/livekit_client/ios"
|
||||
media_kit_libs_ios_video:
|
||||
@ -482,7 +487,8 @@ SPEC CHECKSUMS:
|
||||
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
||||
image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
livekit_client: 9c8080879256a0fb16da13c9be4845248209d896
|
||||
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
|
||||
livekit_client: 20e01637431bc108dad451c8a11c1d206e1dd2cd
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
|
@ -1,87 +1,92 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string></string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>solink</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>FirebaseMessagingAutoInitEnabled</key>
|
||||
<false/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Solian</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>solian</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Allow you take photo/video for your message or post</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Allow you record audio for your message or post</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Allow you add photo to your message or post</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
<string>voip</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>FlutterDeepLinkingEnabled</key>
|
||||
<true/>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string></string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>solink</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>FirebaseMessagingAutoInitEnabled</key>
|
||||
<false/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Solian</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>solian</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Allow you take photo/video for your message or post</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Allow you record audio for your message or post</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Allow you add photo to your message or post</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
<string>voip</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>FlutterDeepLinkingEnabled</key>
|
||||
<true/>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>zh_CN</string>
|
||||
<string>en</string>
|
||||
</array>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -1,20 +1,23 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:solian/exceptions/request.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/channel.dart';
|
||||
import 'package:solian/providers/content/realm.dart';
|
||||
import 'package:solian/providers/relation.dart';
|
||||
import 'package:solian/providers/theme_switcher.dart';
|
||||
import 'package:solian/providers/websocket.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
import 'package:flutter_app_update/flutter_app_update.dart';
|
||||
import 'package:version/version.dart';
|
||||
@ -41,6 +44,106 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
|
||||
|
||||
final Completer _bootCompleter = Completer();
|
||||
|
||||
void _requestRating() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.containsKey('first_boot_time')) {
|
||||
final rawTime = prefs.getString('first_boot_time');
|
||||
final time = DateTime.tryParse(rawTime ?? '');
|
||||
if (time != null &&
|
||||
time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
|
||||
final inAppReview = InAppReview.instance;
|
||||
if (prefs.getBool('rating_requested') == true) return;
|
||||
if (await inAppReview.isAvailable()) {
|
||||
await inAppReview.requestReview();
|
||||
prefs.setBool('rating_requested', true);
|
||||
} else {
|
||||
log('Unable request app review, unavailable');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
prefs.setString('first_boot_time', DateTime.now().toIso8601String());
|
||||
}
|
||||
}
|
||||
|
||||
void _updateNow(String localVersionString, String remoteVersionString) {
|
||||
context
|
||||
.showConfirmDialog(
|
||||
'updateAvailable'.tr,
|
||||
'updateAvailableDesc'.trParams({
|
||||
'from': localVersionString,
|
||||
'to': remoteVersionString,
|
||||
}),
|
||||
)
|
||||
.then((result) {
|
||||
if (result) {
|
||||
final model = UpdateModel(
|
||||
'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk',
|
||||
'solian-app-arm64-v8a-release.apk',
|
||||
'ic_launcher',
|
||||
'https://testflight.apple.com/join/YJ0lmN6O',
|
||||
);
|
||||
AzhonAppUpdate.update(model);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkForUpdate() async {
|
||||
if (PlatformInfo.isWeb) return;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final localVersionString = '${info.version}+${info.buildNumber}';
|
||||
final resp = await GetConnect(
|
||||
timeout: const Duration(seconds: 60),
|
||||
).get(
|
||||
'https://git.solsynth.dev/api/v1/repos/hydrogen/solian/tags?page=1&limit=1',
|
||||
);
|
||||
if (resp.statusCode != 200) {
|
||||
throw RequestException(resp);
|
||||
}
|
||||
final remoteVersionString =
|
||||
(resp.body as List).firstOrNull?['name'] ?? '0.0.0+0';
|
||||
final remoteVersion = Version.parse(remoteVersionString.split('+').first);
|
||||
final localVersion = Version.parse(localVersionString.split('+').first);
|
||||
final remoteBuildNumber =
|
||||
int.tryParse(remoteVersionString.split('+').last) ?? 0;
|
||||
final localBuildNumber =
|
||||
int.tryParse(localVersionString.split('+').last) ?? 0;
|
||||
final strictUpdate = prefs.getBool('check_update_strictly') ?? false;
|
||||
if (remoteVersion > localVersion ||
|
||||
(remoteVersion == localVersion &&
|
||||
remoteBuildNumber > localBuildNumber) ||
|
||||
(remoteVersionString != localVersionString && strictUpdate)) {
|
||||
if (PlatformInfo.isAndroid) {
|
||||
_updateNow(localVersionString, remoteVersionString);
|
||||
} else {
|
||||
context.showInfoDialog(
|
||||
'updateAvailable'.tr,
|
||||
'bsCheckForUpdateDesc'.tr,
|
||||
);
|
||||
}
|
||||
} else if (remoteVersionString != localVersionString) {
|
||||
_bootCompleter.future.then((_) {
|
||||
context.showSnackbar(
|
||||
'updateMayAvailable'.trParams({
|
||||
'version': remoteVersionString,
|
||||
}),
|
||||
action: PlatformInfo.isAndroid
|
||||
? SnackBarAction(
|
||||
label: 'updateNow'.tr,
|
||||
onPressed: () {
|
||||
_updateNow(localVersionString, remoteVersionString);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
context.showErrorDialog('Unable to check update: $e');
|
||||
}
|
||||
}
|
||||
|
||||
late final List<({String label, Future<void> Function() action})> _periods = [
|
||||
(
|
||||
label: 'bsLoadingTheme',
|
||||
@ -48,68 +151,6 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
|
||||
await context.read<ThemeSwitcher>().restoreTheme();
|
||||
},
|
||||
),
|
||||
(
|
||||
label: 'bsCheckForUpdate',
|
||||
action: () async {
|
||||
if (PlatformInfo.isWeb) return;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final localVersionString = '${info.version}+${info.buildNumber}';
|
||||
final resp = await GetConnect().get(
|
||||
'https://git.solsynth.dev/api/v1/repos/hydrogen/solian/tags?page=1&limit=1',
|
||||
);
|
||||
final remoteVersionString =
|
||||
(resp.body as List).firstOrNull?['name'] ?? '0.0.0';
|
||||
final remoteVersion = Version.parse(remoteVersionString ?? '0.0.0');
|
||||
final localVersion =
|
||||
Version.parse(localVersionString.split('+').first);
|
||||
final strictUpdate = prefs.getBool('check_update_strictly') ?? false;
|
||||
if (remoteVersion > localVersion ||
|
||||
(remoteVersionString != localVersionString && strictUpdate)) {
|
||||
setState(() {
|
||||
_isErrored = true;
|
||||
_subtitle = 'bsCheckForUpdateDesc'.tr;
|
||||
});
|
||||
|
||||
if (PlatformInfo.isAndroid) {
|
||||
context
|
||||
.showConfirmDialog(
|
||||
'updateAvailable'.tr,
|
||||
'updateAvailableDesc'.trParams({
|
||||
'from': localVersionString,
|
||||
'to': remoteVersionString,
|
||||
}),
|
||||
)
|
||||
.then((result) {
|
||||
if (result) {
|
||||
final model = UpdateModel(
|
||||
'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk',
|
||||
'solian-app-arm64-v8a-release.apk',
|
||||
'ic_launcher',
|
||||
'https://testflight.apple.com/join/YJ0lmN6O',
|
||||
);
|
||||
AzhonAppUpdate.update(model);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isErrored = true;
|
||||
_subtitle = 'bsCheckForUpdateDesc'.tr;
|
||||
});
|
||||
}
|
||||
} else if (remoteVersionString != localVersionString) {
|
||||
_bootCompleter.future.then((_) {
|
||||
context.showSnackbar('updateMayAvailable'.trParams({
|
||||
'version': remoteVersionString,
|
||||
}));
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
context.showErrorDialog('Unable to check update: $e');
|
||||
}
|
||||
},
|
||||
),
|
||||
(
|
||||
label: 'bsCheckingServer',
|
||||
action: () async {
|
||||
@ -157,8 +198,6 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
|
||||
final AuthProvider auth = Get.find();
|
||||
try {
|
||||
await Future.wait([
|
||||
if (auth.isAuthorized.isTrue)
|
||||
Get.find<ChannelProvider>().refreshAvailableChannel(),
|
||||
if (auth.isAuthorized.isTrue)
|
||||
Get.find<RelationshipProvider>().refreshRelativeList(),
|
||||
if (auth.isAuthorized.isTrue)
|
||||
@ -207,14 +246,17 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_runPeriods();
|
||||
_checkForUpdate();
|
||||
_bootCompleter.future.then((_) {
|
||||
_requestRating();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isBusy || _isErrored) {
|
||||
return GestureDetector(
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: RootContainer(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
|
@ -1,6 +1,8 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:action_slider/action_slider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/exceptions/request.dart';
|
||||
import 'package:solian/exceptions/unauthorized.dart';
|
||||
@ -73,6 +75,47 @@ extension AppExtensions on BuildContext {
|
||||
false;
|
||||
}
|
||||
|
||||
Future<bool> showSlideToConfirmDialog(String title, body) async {
|
||||
return await showDialog<bool>(
|
||||
useRootNavigator: true,
|
||||
context: this,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(title, textAlign: TextAlign.center),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Text(body, textAlign: TextAlign.center),
|
||||
const Gap(28),
|
||||
ActionSlider.standard(
|
||||
icon: const Icon(Icons.send),
|
||||
iconAlignment: Alignment.center,
|
||||
sliderBehavior: SliderBehavior.move,
|
||||
actionThresholdType: ThresholdType.release,
|
||||
toggleColor: Colors.red,
|
||||
action: (controller) async {
|
||||
controller.success();
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
Navigator.pop(ctx, true);
|
||||
},
|
||||
child: Text('slideToConfirm'.tr),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actionsAlignment: MainAxisAlignment.center,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text('cancel'.tr),
|
||||
)
|
||||
],
|
||||
),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
Future<void> showErrorDialog(dynamic exception) {
|
||||
Widget content = Text(exception.toString().capitalize!);
|
||||
if (exception is UnauthorizedException) {
|
||||
|
@ -57,13 +57,16 @@ void main() async {
|
||||
|
||||
Future<void> _initializeFirebase() async {
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
FlutterError.onError = (errorDetails) {
|
||||
FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
|
||||
};
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
||||
return true;
|
||||
};
|
||||
if (PlatformInfo.isIOS || PlatformInfo.isAndroid || PlatformInfo.isMacOS) {
|
||||
// Initialize firebase crashlytics for the platform that supported
|
||||
FlutterError.onError = (errorDetails) {
|
||||
FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
|
||||
};
|
||||
PlatformDispatcher.instance.onError = (error, stack) {
|
||||
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initializeBackgroundNotificationService() async {
|
||||
|
50
lib/models/theme.dart
Normal file
50
lib/models/theme.dart
Normal file
@ -0,0 +1,50 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'theme.g.dart';
|
||||
|
||||
@JsonSerializable(converters: [ColorConverter()])
|
||||
class SolianThemeData {
|
||||
String id;
|
||||
Color seedColor;
|
||||
String? fontFamily;
|
||||
List<String>? fontFamilyFallback;
|
||||
|
||||
SolianThemeData({
|
||||
required this.id,
|
||||
required this.seedColor,
|
||||
this.fontFamily,
|
||||
this.fontFamilyFallback,
|
||||
});
|
||||
|
||||
factory SolianThemeData.fromJson(Map<String, dynamic> json) =>
|
||||
_$SolianThemeDataFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$SolianThemeDataToJson(this);
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is SolianThemeData) {
|
||||
return id == other.id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class ColorConverter extends JsonConverter<Color, int> {
|
||||
const ColorConverter();
|
||||
|
||||
@override
|
||||
Color fromJson(int json) {
|
||||
return Color(json);
|
||||
}
|
||||
|
||||
@override
|
||||
int toJson(Color object) {
|
||||
return object.value;
|
||||
}
|
||||
}
|
26
lib/models/theme.g.dart
Normal file
26
lib/models/theme.g.dart
Normal file
@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'theme.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SolianThemeData _$SolianThemeDataFromJson(Map<String, dynamic> json) =>
|
||||
SolianThemeData(
|
||||
id: json['id'] as String,
|
||||
seedColor:
|
||||
const ColorConverter().fromJson((json['seed_color'] as num).toInt()),
|
||||
fontFamily: json['font_family'] as String?,
|
||||
fontFamilyFallback: (json['font_family_fallback'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SolianThemeDataToJson(SolianThemeData instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'seed_color': const ColorConverter().toJson(instance.seedColor),
|
||||
'font_family': instance.fontFamily,
|
||||
'font_family_fallback': instance.fontFamilyFallback,
|
||||
};
|
@ -27,6 +27,10 @@ abstract class PlatformInfo {
|
||||
|
||||
static bool get canCacheImage => isAndroid || isIOS || isMacOS;
|
||||
|
||||
static bool get canRateTheApp => isIOS || isMacOS;
|
||||
|
||||
static bool get canCropImage => isIOS || isAndroid || isWeb;
|
||||
|
||||
static bool get canRecord => (isMobile || isMacOS);
|
||||
|
||||
static bool get canPushNotification => isAndroid || isIOS || isMacOS;
|
||||
@ -38,4 +42,4 @@ abstract class PlatformInfo {
|
||||
} catch (_) {}
|
||||
return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -392,7 +392,7 @@ class ChatCallProvider extends GetxController {
|
||||
}
|
||||
|
||||
Future gotoScreen(BuildContext context) {
|
||||
return Navigator.of(context, rootNavigator: true).push(
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const CallScreen()),
|
||||
);
|
||||
}
|
||||
|
@ -9,25 +9,6 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChannelProvider extends GetxController {
|
||||
RxBool isLoading = false.obs;
|
||||
RxList<Channel> availableChannels = RxList.empty(growable: true);
|
||||
|
||||
List<Channel> get groupChannels =>
|
||||
availableChannels.where((x) => x.type == 0).toList();
|
||||
List<Channel> get directChannels =>
|
||||
availableChannels.where((x) => x.type == 1).toList();
|
||||
|
||||
Future<void> refreshAvailableChannel() async {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
|
||||
|
||||
isLoading.value = true;
|
||||
final resp = await listAvailableChannel();
|
||||
isLoading.value = false;
|
||||
|
||||
availableChannels.value =
|
||||
resp.body.map((x) => Channel.fromJson(x)).toList().cast<Channel>();
|
||||
availableChannels.refresh();
|
||||
}
|
||||
|
||||
Future<Response> getChannel(String alias, {String realm = 'global'}) async {
|
||||
final AuthProvider auth = Get.find();
|
||||
@ -89,18 +70,22 @@ class ChannelProvider extends GetxController {
|
||||
return resp;
|
||||
}
|
||||
|
||||
Future<Response> listAvailableChannel({String scope = 'global'}) async {
|
||||
Future<List<Channel>> listAvailableChannel({
|
||||
String scope = 'global',
|
||||
bool isDirect = false,
|
||||
}) async {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
|
||||
|
||||
final client = await auth.configureClient('messaging');
|
||||
|
||||
final resp = await client.get('/channels/$scope/me/available');
|
||||
final resp =
|
||||
await client.get('/channels/$scope/me/available?direct=$isDirect');
|
||||
if (resp.statusCode != 200) {
|
||||
throw RequestException(resp);
|
||||
}
|
||||
|
||||
return resp;
|
||||
return List.from(resp.body.map((x) => Channel.fromJson(x)));
|
||||
}
|
||||
|
||||
Future<Response> createChannel(String scope, dynamic payload) async {
|
||||
|
@ -1,3 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:get/get.dart' hide Value;
|
||||
import 'package:solian/exceptions/request.dart';
|
||||
@ -182,4 +185,26 @@ class MessagesFetchingProvider extends GetxController {
|
||||
..orderBy([(t) => OrderingTerm.desc(t.id)]))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<Map<int, List<LocalMessageEventTableData>>>
|
||||
getLastInAllChannels() async {
|
||||
final database = Get.find<DatabaseProvider>().database;
|
||||
final rows = await database.customSelect('''
|
||||
SELECT id, channel_id, data, created_at
|
||||
FROM ${database.localMessageEventTable.actualTableName}
|
||||
WHERE (channel_id, created_at) IN (
|
||||
SELECT channel_id, MAX(created_at)
|
||||
FROM ${database.localMessageEventTable.actualTableName}
|
||||
GROUP BY channel_id
|
||||
)
|
||||
''', readsFrom: {database.localMessageEventTable}).get();
|
||||
return rows.map((row) {
|
||||
return LocalMessageEventTableData(
|
||||
id: row.read<int>('id'),
|
||||
channelId: row.read<int>('channel_id'),
|
||||
data: Event.fromJson(jsonDecode(row.read<String>('data'))),
|
||||
createdAt: row.read<DateTime>('created_at'),
|
||||
);
|
||||
}).groupListsBy((x) => x.channelId);
|
||||
}
|
||||
}
|
||||
|
@ -26,6 +26,19 @@ class RelationshipProvider extends GetxController {
|
||||
return _friends.any((x) => x.relatedId == account.id);
|
||||
}
|
||||
|
||||
Future<Relationship?> getRelationship(int relatedId) async {
|
||||
final AuthProvider auth = Get.find();
|
||||
final client = await auth.configureClient('auth');
|
||||
final resp = await client.get('/users/me/relations/$relatedId');
|
||||
if (resp.statusCode == 404) {
|
||||
return null;
|
||||
} else if (resp.statusCode != 200) {
|
||||
throw RequestException(resp);
|
||||
}
|
||||
|
||||
return Relationship.fromJson(resp.body);
|
||||
}
|
||||
|
||||
Future<Response> listRelation() async {
|
||||
final AuthProvider auth = Get.find();
|
||||
final client = await auth.configureClient('auth');
|
||||
@ -38,7 +51,19 @@ class RelationshipProvider extends GetxController {
|
||||
return client.get('/users/me/relations?status=$status');
|
||||
}
|
||||
|
||||
Future<Response> makeFriend(String username) async {
|
||||
Future<Relationship?> blockUser(String username) async {
|
||||
final AuthProvider auth = Get.find();
|
||||
final client = await auth.configureClient('auth');
|
||||
final resp =
|
||||
await client.post('/users/me/relations/block?related=$username', {});
|
||||
if (resp.statusCode != 200) {
|
||||
throw RequestException(resp);
|
||||
}
|
||||
|
||||
return Relationship.fromJson(resp.body);
|
||||
}
|
||||
|
||||
Future<Relationship?> makeFriend(String username) async {
|
||||
final AuthProvider auth = Get.find();
|
||||
final client = await auth.configureClient('auth');
|
||||
final resp = await client.post('/users/me/relations?related=$username', {});
|
||||
@ -46,7 +71,7 @@ class RelationshipProvider extends GetxController {
|
||||
throw RequestException(resp);
|
||||
}
|
||||
|
||||
return resp;
|
||||
return Relationship.fromJson(resp.body);
|
||||
}
|
||||
|
||||
Future<Response> handleRelation(
|
||||
@ -64,17 +89,17 @@ class RelationshipProvider extends GetxController {
|
||||
return resp;
|
||||
}
|
||||
|
||||
Future<Response> editRelation(Relationship relationship, int status) async {
|
||||
Future<Relationship?> editRelation(int relatedId, int status) async {
|
||||
final AuthProvider auth = Get.find();
|
||||
final client = await auth.configureClient('auth');
|
||||
final resp = await client.patch(
|
||||
'/users/me/relations/${relationship.relatedId}',
|
||||
final resp = await client.put(
|
||||
'/users/me/relations/$relatedId',
|
||||
{'status': status},
|
||||
);
|
||||
if (resp.statusCode != 200) {
|
||||
throw RequestException(resp);
|
||||
}
|
||||
|
||||
return resp;
|
||||
return Relationship.fromJson(resp.body);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:solian/models/theme.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
|
||||
class ThemeSwitcher extends ChangeNotifier {
|
||||
@ -13,11 +16,21 @@ class ThemeSwitcher extends ChangeNotifier {
|
||||
|
||||
Future<void> restoreTheme() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.containsKey('global_theme_color')) {
|
||||
final value = prefs.getInt('global_theme_color')!;
|
||||
final color = Color(value);
|
||||
lightThemeData = AppTheme.build(Brightness.light, seedColor: color);
|
||||
darkThemeData = AppTheme.build(Brightness.dark, seedColor: color);
|
||||
if (prefs.containsKey('global_theme')) {
|
||||
final value = SolianThemeData.fromJson(
|
||||
jsonDecode(prefs.getString('global_theme')!),
|
||||
);
|
||||
final agedTheme = prefs.getBool('aged_theme');
|
||||
lightThemeData = AppTheme.buildFromData(
|
||||
Brightness.light,
|
||||
value,
|
||||
useMaterial3: agedTheme == null ? true : !agedTheme,
|
||||
);
|
||||
darkThemeData = AppTheme.buildFromData(
|
||||
Brightness.dark,
|
||||
value,
|
||||
useMaterial3: agedTheme == null ? true : !agedTheme,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@ -27,4 +40,25 @@ class ThemeSwitcher extends ChangeNotifier {
|
||||
darkThemeData = dark;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setThemeData(SolianThemeData? data) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (data == null) {
|
||||
prefs.remove('global_theme');
|
||||
} else {
|
||||
prefs.setString(
|
||||
'global_theme',
|
||||
jsonEncode(data.toJson()),
|
||||
);
|
||||
lightThemeData = AppTheme.buildFromData(Brightness.light, data);
|
||||
darkThemeData = AppTheme.buildFromData(Brightness.dark, data);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setAgedTheme(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setBool('aged_theme', enabled);
|
||||
await restoreTheme();
|
||||
}
|
||||
}
|
||||
|
@ -23,11 +23,13 @@ import 'package:solian/screens/realms.dart';
|
||||
import 'package:solian/screens/realms/realm_detail.dart';
|
||||
import 'package:solian/screens/realms/realm_organize.dart';
|
||||
import 'package:solian/screens/realms/realm_view.dart';
|
||||
import 'package:solian/screens/feed.dart';
|
||||
import 'package:solian/screens/explore.dart';
|
||||
import 'package:solian/screens/posts/post_editor.dart';
|
||||
import 'package:solian/screens/settings.dart';
|
||||
import 'package:solian/shells/root_shell.dart';
|
||||
import 'package:solian/shells/title_shell.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/sidebar/empty_placeholder.dart';
|
||||
|
||||
abstract class AppRouter {
|
||||
static GoRouter instance = GoRouter(
|
||||
@ -78,13 +80,18 @@ abstract class AppRouter {
|
||||
builder: (context, state, child) => child,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/feed',
|
||||
name: 'feed',
|
||||
builder: (context, state) => const FeedScreen(),
|
||||
path: '/explore',
|
||||
name: 'explore',
|
||||
builder: (context, state) => const ExploreScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/feed/search',
|
||||
name: 'feedSearch',
|
||||
path: '/drafts',
|
||||
name: 'draftBox',
|
||||
builder: (context, state) => const DraftBoxScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/posts/search',
|
||||
name: 'postSearch',
|
||||
builder: (context, state) => TitleShell(
|
||||
state: state,
|
||||
child: FeedSearchScreen(
|
||||
@ -93,11 +100,6 @@ abstract class AppRouter {
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/drafts',
|
||||
name: 'draftBox',
|
||||
builder: (context, state) => const DraftBoxScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/posts/view/:id',
|
||||
name: 'postDetail',
|
||||
@ -137,12 +139,15 @@ abstract class AppRouter {
|
||||
);
|
||||
|
||||
static final ShellRoute _chatRoute = ShellRoute(
|
||||
builder: (context, state, child) => child,
|
||||
builder: (context, state, child) =>
|
||||
AppTheme.isLargeScreen(context) ? ChatListShell(child: child) : child,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
builder: (context, state) => const ChatScreen(),
|
||||
builder: (context, state) => AppTheme.isLargeScreen(context)
|
||||
? const EmptyPagePlaceholder()
|
||||
: const ChatScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/chat/organize',
|
||||
|
@ -1,6 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class AboutScreen extends StatelessWidget {
|
||||
@ -11,8 +16,7 @@ class AboutScreen extends StatelessWidget {
|
||||
const denseButtonStyle =
|
||||
ButtonStyle(visualDensity: VisualDensity(vertical: -4));
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
@ -47,31 +51,58 @@ class AboutScreen extends StatelessWidget {
|
||||
),
|
||||
Text('Copyright © ${DateTime.now().year} Solsynth LLC'),
|
||||
const Gap(16),
|
||||
TextButton(
|
||||
style: denseButtonStyle,
|
||||
child: const Text('App Details'),
|
||||
onPressed: () async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
CenteredContainer(
|
||||
maxWidth: 280,
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
TextButton(
|
||||
style: denseButtonStyle,
|
||||
child: Text('appDetails'.tr),
|
||||
onPressed: () async {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationVersion: '${info.version} (${info.buildNumber})',
|
||||
applicationLegalese:
|
||||
'The Solar Network App is an intuitive and self-hostable social network and computing platform. Experience the freedom of a user-friendly design that empowers you to create and connect with communities on your own terms. Embrace the future of social networking with a platform that prioritizes your independence and privacy.',
|
||||
applicationIcon: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
child:
|
||||
Image.asset('assets/logo.png', width: 60, height: 60),
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationVersion:
|
||||
'${info.version} (${info.buildNumber})',
|
||||
applicationLegalese:
|
||||
'The Solar Network App is an intuitive and open-source social network and computing platform. Experience the freedom of a user-friendly design that empowers you to create and connect with communities on your own terms. Embrace the future of social networking with a platform that prioritizes your independence and privacy.',
|
||||
applicationIcon: ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(16)),
|
||||
child: Image.asset('assets/logo.png',
|
||||
width: 60, height: 60),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
style: denseButtonStyle,
|
||||
child: const Text('Project Website'),
|
||||
onPressed: () {
|
||||
launchUrlString('https://solsynth.dev/products/solar-network');
|
||||
},
|
||||
TextButton(
|
||||
style: denseButtonStyle,
|
||||
child: Text('projectWebsite'.tr),
|
||||
onPressed: () {
|
||||
launchUrlString(
|
||||
'https://solsynth.dev/products/solar-network');
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
style: denseButtonStyle,
|
||||
child: Text('termRelated'.tr),
|
||||
onPressed: () {
|
||||
launchUrlString('https://solsynth.dev/terms');
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
style: denseButtonStyle,
|
||||
child: Text('serviceStatus'.tr),
|
||||
onPressed: () {
|
||||
launchUrlString('https://status.solsynth.dev');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
const Text(
|
||||
@ -81,6 +112,34 @@ class AboutScreen extends StatelessWidget {
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
FutureBuilder(
|
||||
future: SharedPreferences.getInstance(),
|
||||
builder: (context, snapshot) {
|
||||
const textStyle = TextStyle(
|
||||
fontWeight: FontWeight.w300,
|
||||
fontSize: 12,
|
||||
);
|
||||
if (!snapshot.hasData ||
|
||||
!snapshot.data!.containsKey('first_boot_time')) {
|
||||
return Text(
|
||||
'firstBootTime'.trParams({'time': 'unknown'.tr}),
|
||||
style: textStyle,
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
'firstBootTime'.trParams({
|
||||
'time': DateFormat('yyyy-MM-dd').format(
|
||||
DateTime.tryParse(
|
||||
snapshot.data!.getString('first_boot_time')!,
|
||||
)?.toLocal() ??
|
||||
DateTime.now(),
|
||||
),
|
||||
}),
|
||||
style: textStyle,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -7,6 +7,7 @@ import 'package:solian/providers/account_status.dart';
|
||||
import 'package:solian/providers/relation.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/widgets/account/account_heading.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
|
||||
@ -49,8 +50,7 @@ class _AccountScreenState extends State<AccountScreen> {
|
||||
|
||||
final AuthProvider auth = Get.find();
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: SafeArea(
|
||||
child: Obx(() {
|
||||
if (auth.isAuthorized.isFalse) {
|
||||
|
@ -6,6 +6,7 @@ import 'package:solian/models/relations.dart';
|
||||
import 'package:solian/providers/relation.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/account/relative_list.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class FriendScreen extends StatefulWidget {
|
||||
const FriendScreen({super.key});
|
||||
@ -117,8 +118,7 @@ class _FriendScreenState extends State<FriendScreen>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: false,
|
||||
|
@ -6,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:solian/exceptions/request.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class NotificationPreferencesScreen extends StatefulWidget {
|
||||
const NotificationPreferencesScreen({super.key});
|
||||
@ -74,8 +75,7 @@ class _NotificationPreferencesScreenState
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: Column(
|
||||
children: [
|
||||
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
|
||||
|
@ -1,5 +1,3 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
@ -9,10 +7,12 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/attachment.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/attachment.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class PersonalizeScreen extends StatefulWidget {
|
||||
const PersonalizeScreen({super.key});
|
||||
@ -77,36 +77,42 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (auth.isAuthorized.isFalse) return;
|
||||
|
||||
XFile file;
|
||||
|
||||
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
|
||||
if (image == null) return;
|
||||
|
||||
CroppedFile? croppedFile = await ImageCropper().cropImage(
|
||||
sourcePath: image.path,
|
||||
uiSettings: [
|
||||
AndroidUiSettings(
|
||||
toolbarTitle: 'cropImage'.tr,
|
||||
toolbarColor: Theme.of(context).colorScheme.primary,
|
||||
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
IOSUiSettings(
|
||||
title: 'cropImage'.tr,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
WebUiSettings(
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (PlatformInfo.canCropImage) {
|
||||
CroppedFile? croppedFile = await ImageCropper().cropImage(
|
||||
sourcePath: image.path,
|
||||
uiSettings: [
|
||||
AndroidUiSettings(
|
||||
toolbarTitle: 'cropImage'.tr,
|
||||
toolbarColor: Theme.of(context).colorScheme.primary,
|
||||
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
IOSUiSettings(
|
||||
title: 'cropImage'.tr,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
WebUiSettings(
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (croppedFile == null) return;
|
||||
final file = File(croppedFile.path);
|
||||
if (croppedFile == null) return;
|
||||
file = XFile(croppedFile.path);
|
||||
} else {
|
||||
file = XFile(image.path);
|
||||
}
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
@ -181,8 +187,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
const double padding = 32;
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: ListView(
|
||||
children: [
|
||||
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
|
||||
|
@ -1,13 +1,19 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:solian/controllers/post_list_controller.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/account.dart';
|
||||
import 'package:solian/models/attachment.dart';
|
||||
import 'package:solian/models/daily_sign.dart';
|
||||
import 'package:solian/models/pagination.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
import 'package:solian/models/relations.dart';
|
||||
import 'package:solian/models/subscription.dart';
|
||||
import 'package:solian/providers/account_status.dart';
|
||||
import 'package:solian/providers/relation.dart';
|
||||
@ -18,8 +24,11 @@ import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/account/account_heading.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/attachments/attachment_list.dart';
|
||||
import 'package:solian/widgets/daily_sign/history_chart.dart';
|
||||
import 'package:solian/widgets/posts/post_list.dart';
|
||||
import 'package:solian/widgets/posts/post_warped_list.dart';
|
||||
import 'package:solian/widgets/reports/abuse_report.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
|
||||
class AccountProfilePage extends StatefulWidget {
|
||||
@ -44,7 +53,9 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
|
||||
Account? _userinfo;
|
||||
Subscription? _subscription;
|
||||
Relationship? _relationship;
|
||||
List<Post> _pinnedPosts = List.empty();
|
||||
List<DailySignRecord> _dailySignRecords = List.empty();
|
||||
int _totalUpvote = 0, _totalDownvote = 0;
|
||||
|
||||
Future<void> _getSubscription() async {
|
||||
@ -54,10 +65,19 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
setState(() => _isSubscribing = false);
|
||||
}
|
||||
|
||||
Future<void> _getRelationship() async {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final relations = Get.find<RelationshipProvider>();
|
||||
_relationship = await relations.getRelationship(_userinfo!.id);
|
||||
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
|
||||
Future<void> _getUserinfo() async {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
var client = await ServiceFinder.configureClient('auth');
|
||||
var client = await ServiceFinder.configureClient('id');
|
||||
var resp = await client.get('/users/${widget.name}');
|
||||
if (resp.statusCode != 200) {
|
||||
context.showErrorDialog(resp.bodyString).then((_) {
|
||||
@ -67,7 +87,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
_userinfo = Account.fromJson(resp.body);
|
||||
}
|
||||
|
||||
client = await ServiceFinder.configureClient('interactive');
|
||||
client = await ServiceFinder.configureClient('co');
|
||||
resp = await client.get('/users/${widget.name}');
|
||||
if (resp.statusCode != 200) {
|
||||
context.showErrorDialog(resp.bodyString).then((_) {
|
||||
@ -82,7 +102,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
}
|
||||
|
||||
Future<void> _getPinnedPosts() async {
|
||||
final client = await ServiceFinder.configureClient('interactive');
|
||||
final client = await ServiceFinder.configureClient('co');
|
||||
final resp = await client.get('/users/${widget.name}/pin');
|
||||
if (resp.statusCode != 200) {
|
||||
context.showErrorDialog(resp.bodyString).then((_) {
|
||||
@ -96,6 +116,80 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _getDailySignRecords() async {
|
||||
final client = await ServiceFinder.configureClient('id');
|
||||
final resp = await client.get('/users/${widget.name}/daily?take=14');
|
||||
if (resp.statusCode != 200) {
|
||||
context.showErrorDialog(resp.bodyString).then((_) {
|
||||
Navigator.pop(context);
|
||||
});
|
||||
} else {
|
||||
final result = PaginationResult.fromJson(resp.body);
|
||||
setState(() {
|
||||
_dailySignRecords = List.from(
|
||||
result.data?.map((x) => DailySignRecord.fromJson(x)) ?? [],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _subscribeToUser() async {
|
||||
setState(() => _isSubscribing = true);
|
||||
_subscription =
|
||||
await Get.find<SubscriptionProvider>().subscribeToUser(_userinfo!.id);
|
||||
setState(() => _isSubscribing = false);
|
||||
}
|
||||
|
||||
Future<void> _unsubscribeFromUser() async {
|
||||
setState(() => _isSubscribing = true);
|
||||
await Get.find<SubscriptionProvider>().unsubscribeFromUser(_userinfo!.id);
|
||||
_subscription = null;
|
||||
setState(() => _isSubscribing = false);
|
||||
}
|
||||
|
||||
Future<void> _makeFriend() async {
|
||||
setState(() => _isMakingFriend = true);
|
||||
try {
|
||||
_relationship = await _relationshipProvider.makeFriend(widget.name);
|
||||
context.showSnackbar(
|
||||
'accountFriendRequestSent'.tr,
|
||||
);
|
||||
} catch (e) {
|
||||
context.showErrorDialog(e);
|
||||
} finally {
|
||||
setState(() => _isMakingFriend = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _blockUser() async {
|
||||
setState(() => _isMakingFriend = true);
|
||||
try {
|
||||
_relationship = await _relationshipProvider.blockUser(widget.name);
|
||||
context.showSnackbar(
|
||||
'accountBlocked'.tr,
|
||||
);
|
||||
} catch (e) {
|
||||
context.showErrorDialog(e);
|
||||
} finally {
|
||||
setState(() => _isMakingFriend = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unblockUser() async {
|
||||
setState(() => _isMakingFriend = true);
|
||||
try {
|
||||
_relationship =
|
||||
await _relationshipProvider.editRelation(_userinfo!.id, 1);
|
||||
context.showSnackbar(
|
||||
'accountUnblocked'.tr,
|
||||
);
|
||||
} catch (e) {
|
||||
context.showErrorDialog(e);
|
||||
} finally {
|
||||
setState(() => _isMakingFriend = false);
|
||||
}
|
||||
}
|
||||
|
||||
int get _userSocialCreditPoints {
|
||||
return _totalUpvote * 2 - _totalDownvote + _postController.postTotal.value;
|
||||
}
|
||||
@ -127,36 +221,20 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
});
|
||||
|
||||
_getUserinfo().then((_) {
|
||||
_getRelationship();
|
||||
_getSubscription();
|
||||
_getPinnedPosts();
|
||||
_getDailySignRecords();
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildStatisticsEntry(String label, String content) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
content,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isBusy || _userinfo == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: DefaultTabController(
|
||||
length: 3,
|
||||
child: NestedScrollView(
|
||||
@ -168,96 +246,71 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
leadingWidth: 24,
|
||||
automaticallyImplyLeading: false,
|
||||
flexibleSpace: Row(
|
||||
children: [
|
||||
AppBarLeadingButton.adaptive(context) ?? const Gap(8),
|
||||
const Gap(8),
|
||||
if (_userinfo != null)
|
||||
AccountAvatar(content: _userinfo!.avatar, radius: 16),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_userinfo != null)
|
||||
Text(
|
||||
_userinfo!.nick,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
if (_userinfo != null)
|
||||
Text(
|
||||
'@${_userinfo!.name}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_userinfo != null && _subscription == null)
|
||||
OutlinedButton(
|
||||
style: const ButtonStyle(
|
||||
visualDensity:
|
||||
VisualDensity(horizontal: -4, vertical: -2),
|
||||
flexibleSpace: SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
AppBarLeadingButton.adaptive(context) ?? const Gap(8),
|
||||
const Gap(8),
|
||||
if (_userinfo != null)
|
||||
AccountAvatar(content: _userinfo!.avatar, radius: 16),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_userinfo != null)
|
||||
Text(
|
||||
_userinfo!.nick,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
if (_userinfo != null)
|
||||
Text(
|
||||
'@${_userinfo!.name}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
onPressed: _isSubscribing
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isSubscribing = true);
|
||||
_subscription =
|
||||
await Get.find<SubscriptionProvider>()
|
||||
.subscribeToUser(_userinfo!.id);
|
||||
setState(() => _isSubscribing = false);
|
||||
},
|
||||
child: Text('subscribe'.tr),
|
||||
)
|
||||
else if (_userinfo != null)
|
||||
OutlinedButton(
|
||||
style: const ButtonStyle(
|
||||
visualDensity:
|
||||
VisualDensity(horizontal: -4, vertical: -2),
|
||||
),
|
||||
if (_userinfo != null && _subscription == null)
|
||||
IconButton(
|
||||
style: const ButtonStyle(
|
||||
visualDensity:
|
||||
VisualDensity(horizontal: -4, vertical: -2),
|
||||
),
|
||||
onPressed: _isSubscribing ? null : _subscribeToUser,
|
||||
icon: const Icon(Icons.add_circle_outline),
|
||||
tooltip: 'subscribe'.tr,
|
||||
)
|
||||
else if (_userinfo != null)
|
||||
IconButton(
|
||||
style: const ButtonStyle(
|
||||
visualDensity:
|
||||
VisualDensity(horizontal: -4, vertical: -2),
|
||||
),
|
||||
onPressed:
|
||||
_isSubscribing ? null : _unsubscribeFromUser,
|
||||
icon: const Icon(Icons.remove_circle_outline),
|
||||
tooltip: 'unsubscribe'.tr,
|
||||
),
|
||||
onPressed: _isSubscribing
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isSubscribing = true);
|
||||
await Get.find<SubscriptionProvider>()
|
||||
.unsubscribeFromUser(_userinfo!.id);
|
||||
_subscription = null;
|
||||
setState(() => _isSubscribing = false);
|
||||
},
|
||||
child: Text('unsubscribe'.tr),
|
||||
if (_userinfo != null && _relationship == null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_add),
|
||||
onPressed: _isMakingFriend ? null : _makeFriend,
|
||||
tooltip: 'friendAdd'.tr,
|
||||
)
|
||||
else
|
||||
const IconButton(
|
||||
icon: Icon(Icons.handshake),
|
||||
onPressed: null,
|
||||
),
|
||||
SizedBox(
|
||||
width: AppTheme.isLargeScreen(context) ? 8 : 16,
|
||||
),
|
||||
if (_userinfo != null &&
|
||||
!_relationshipProvider.hasFriend(_userinfo!))
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_add),
|
||||
onPressed: _isMakingFriend
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _isMakingFriend = true);
|
||||
try {
|
||||
await _relationshipProvider
|
||||
.makeFriend(widget.name);
|
||||
context.showSnackbar(
|
||||
'accountFriendRequestSent'.tr,
|
||||
);
|
||||
} catch (e) {
|
||||
context.showErrorDialog(e);
|
||||
} finally {
|
||||
setState(() => _isMakingFriend = false);
|
||||
}
|
||||
},
|
||||
)
|
||||
else
|
||||
const IconButton(
|
||||
icon: Icon(Icons.handshake),
|
||||
onPressed: null,
|
||||
),
|
||||
SizedBox(
|
||||
width: AppTheme.isLargeScreen(context) ? 8 : 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
).paddingOnly(top: MediaQuery.of(context).padding.top),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'profilePage'.tr),
|
||||
@ -271,21 +324,205 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
body: TabBarView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
Column(
|
||||
ListView(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 16),
|
||||
children: [
|
||||
const Gap(16),
|
||||
AccountHeadingWidget(
|
||||
name: _userinfo!.name,
|
||||
nick: _userinfo!.nick,
|
||||
desc: _userinfo!.description,
|
||||
badges: _userinfo!.badges,
|
||||
banner: _userinfo!.banner,
|
||||
avatar: _userinfo!.avatar,
|
||||
status: Get.find<StatusProvider>()
|
||||
.getSomeoneStatus(_userinfo!.name),
|
||||
detail: _userinfo,
|
||||
profile: _userinfo!.profile,
|
||||
extraWidgets: const [],
|
||||
CenteredContainer(
|
||||
child: AccountHeadingWidget(
|
||||
name: _userinfo!.name,
|
||||
nick: _userinfo!.nick,
|
||||
desc: _userinfo!.description,
|
||||
badges: _userinfo!.badges,
|
||||
banner: _userinfo!.banner,
|
||||
avatar: _userinfo!.avatar,
|
||||
status: Get.find<StatusProvider>()
|
||||
.getSomeoneStatus(_userinfo!.name),
|
||||
detail: _userinfo,
|
||||
profile: _userinfo!.profile,
|
||||
extraWidgets: [
|
||||
if (_dailySignRecords.isNotEmpty)
|
||||
Card(
|
||||
child: SizedBox(
|
||||
height: 180,
|
||||
width:
|
||||
max(640, MediaQuery.of(context).size.width),
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
isCurved: true,
|
||||
isStrokeCapRound: true,
|
||||
isStrokeJoinRound: true,
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: List.filled(
|
||||
_dailySignRecords.length,
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.3),
|
||||
).toList(),
|
||||
),
|
||||
),
|
||||
spots: _dailySignRecords
|
||||
.map(
|
||||
(x) => FlSpot(
|
||||
x.createdAt
|
||||
.copyWith(
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
)
|
||||
.millisecondsSinceEpoch
|
||||
.toDouble(),
|
||||
x.resultTier.toDouble(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => spots
|
||||
.map((spot) => LineTooltipItem(
|
||||
'${DailySignHistoryChartDialog.signSymbols[spot.y.toInt()]}\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
|
||||
TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
getTooltipColor: (_) => Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: 1,
|
||||
getTitlesWidget: (value, _) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
DailySignHistoryChartDialog
|
||||
.signSymbols[value.toInt()],
|
||||
textAlign: TextAlign.right,
|
||||
).paddingOnly(right: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
interval: 86400000,
|
||||
getTitlesWidget: (value, _) => Text(
|
||||
DateFormat('dd').format(
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
value.toInt(),
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
).paddingOnly(top: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
gridData: const FlGridData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
),
|
||||
),
|
||||
).marginOnly(
|
||||
right: 24,
|
||||
left: 12,
|
||||
bottom: 8,
|
||||
top: 24,
|
||||
),
|
||||
)
|
||||
],
|
||||
appendWidgets: [
|
||||
Card(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 8,
|
||||
),
|
||||
width: double.maxFinite,
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.spaceAround,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AbuseReportDialog(
|
||||
resourceId: 'user:${_userinfo!.id}',
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.flag,
|
||||
size: 16,
|
||||
),
|
||||
label: Text('reportAbuse'.tr),
|
||||
),
|
||||
if (_relationship?.status != 2)
|
||||
TextButton.icon(
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
onPressed:
|
||||
_isMakingFriend ? null : _blockUser,
|
||||
icon: const Icon(
|
||||
Icons.block,
|
||||
size: 16,
|
||||
),
|
||||
label: Text('blockUser'.tr),
|
||||
)
|
||||
else
|
||||
TextButton.icon(
|
||||
style: const ButtonStyle(
|
||||
visualDensity: VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
onPressed:
|
||||
_isMakingFriend ? null : _unblockUser,
|
||||
icon: const Icon(
|
||||
Icons.add_circle_outline,
|
||||
size: 16,
|
||||
),
|
||||
label: Text('unblockUser'.tr),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -301,7 +538,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_buildStatisticsEntry(
|
||||
_StatsWidget(
|
||||
'totalSocialCreditPoints'.tr,
|
||||
_userinfo != null
|
||||
? _userSocialCreditPoints.toString()
|
||||
@ -314,16 +551,16 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
Obx(
|
||||
() => _buildStatisticsEntry(
|
||||
() => _StatsWidget(
|
||||
'totalPostCount'.tr,
|
||||
_postController.postTotal.value.toString(),
|
||||
),
|
||||
),
|
||||
_buildStatisticsEntry(
|
||||
_StatsWidget(
|
||||
'totalUpvote'.tr,
|
||||
_totalUpvote.toString(),
|
||||
),
|
||||
_buildStatisticsEntry(
|
||||
_StatsWidget(
|
||||
'totalDownvote'.tr,
|
||||
_totalDownvote.toString(),
|
||||
),
|
||||
@ -373,8 +610,9 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
),
|
||||
CenteredContainer(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
Future.sync(() => _albumPagingController.refresh()),
|
||||
onRefresh: () => Future.sync(
|
||||
() => _albumPagingController.refresh(),
|
||||
),
|
||||
child: PagedGridView<int, Attachment>(
|
||||
padding: EdgeInsets.zero,
|
||||
pagingController: _albumPagingController,
|
||||
@ -420,3 +658,28 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatsWidget extends StatelessWidget {
|
||||
final String label;
|
||||
final String content;
|
||||
|
||||
const _StatsWidget(this.label, this.content);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
content,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -7,12 +7,13 @@ import 'package:solian/exceptions/request.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/auth.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/channel.dart';
|
||||
import 'package:solian/providers/content/realm.dart';
|
||||
import 'package:solian/providers/relation.dart';
|
||||
import 'package:solian/providers/websocket.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class SignInScreen extends StatefulWidget {
|
||||
const SignInScreen({super.key});
|
||||
@ -167,7 +168,6 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||
|
||||
final result = AuthResult.fromJson(resp.body);
|
||||
_currentTicket = result.ticket;
|
||||
_passwordController.clear();
|
||||
|
||||
// Finish sign in if possible
|
||||
if (result.isFinished) {
|
||||
@ -177,7 +177,6 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||
await auth.refreshAuthorizeStatus();
|
||||
await auth.refreshUserProfile();
|
||||
|
||||
Get.find<ChannelProvider>().refreshAvailableChannel();
|
||||
Get.find<RealmProvider>().refreshAvailableRealms();
|
||||
Get.find<RelationshipProvider>().refreshRelativeList();
|
||||
Get.find<WebSocketProvider>().registerPushNotifications();
|
||||
@ -185,11 +184,13 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||
autoStartBackgroundNotificationService();
|
||||
|
||||
Navigator.pop(context, true);
|
||||
_passwordController.clear();
|
||||
});
|
||||
} else {
|
||||
// Skip the first step
|
||||
_factorPicked = null;
|
||||
_factorPickedType = null;
|
||||
_passwordController.clear();
|
||||
setState(() => _period += 2);
|
||||
}
|
||||
} catch (e) {
|
||||
@ -210,15 +211,13 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||
case 2:
|
||||
_passwordController.clear();
|
||||
_factorPickedType = null;
|
||||
default:
|
||||
setState(() => _period--);
|
||||
}
|
||||
setState(() => _period--);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: CenteredContainer(
|
||||
maxWidth: 360,
|
||||
child: PageTransitionSwitcher(
|
||||
@ -235,16 +234,18 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||
);
|
||||
},
|
||||
child: switch (_period % 3) {
|
||||
1 => Column(
|
||||
1 => ListView(
|
||||
shrinkWrap: true,
|
||||
key: const ValueKey<int>(1),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child:
|
||||
Image.asset('assets/logo.png', width: 64, height: 64),
|
||||
).paddingOnly(bottom: 8, left: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child:
|
||||
Image.asset('assets/logo.png', width: 64, height: 64),
|
||||
).paddingOnly(bottom: 8, left: 4),
|
||||
),
|
||||
Text(
|
||||
'signinPickFactor'.tr,
|
||||
style: const TextStyle(
|
||||
@ -323,16 +324,18 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
2 => Column(
|
||||
2 => ListView(
|
||||
key: const ValueKey<int>(2),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child:
|
||||
Image.asset('assets/logo.png', width: 64, height: 64),
|
||||
).paddingOnly(bottom: 8, left: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child:
|
||||
Image.asset('assets/logo.png', width: 64, height: 64),
|
||||
).paddingOnly(bottom: 8, left: 4),
|
||||
),
|
||||
Text(
|
||||
'signinEnterPassword'.tr,
|
||||
style: const TextStyle(
|
||||
@ -396,16 +399,18 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
_ => Column(
|
||||
_ => ListView(
|
||||
key: const ValueKey<int>(0),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child:
|
||||
Image.asset('assets/logo.png', width: 64, height: 64),
|
||||
).paddingOnly(bottom: 8, left: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child:
|
||||
Image.asset('assets/logo.png', width: 64, height: 64),
|
||||
).paddingOnly(bottom: 8, left: 4),
|
||||
),
|
||||
Text(
|
||||
'signinGreeting'.tr,
|
||||
style: const TextStyle(
|
||||
@ -451,11 +456,50 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 290),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'termAcceptNextWithAgree'.tr,
|
||||
textAlign: TextAlign.end,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.75),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('termAcceptLink'.tr),
|
||||
const Gap(4),
|
||||
const Icon(Icons.launch, size: 14),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
launchUrlString('https://solsynth.dev/terms');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).paddingSymmetric(horizontal: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
).paddingAll(24),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,9 @@ import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class SignUpScreen extends StatefulWidget {
|
||||
const SignUpScreen({super.key});
|
||||
@ -18,7 +20,7 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||
final _nicknameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
void performAction(BuildContext context) async {
|
||||
void _performAction(BuildContext context) async {
|
||||
final email = _emailController.value.text;
|
||||
final username = _usernameController.value.text;
|
||||
final nickname = _nicknameController.value.text;
|
||||
@ -60,20 +62,23 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isTermAccepted = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: CenteredContainer(
|
||||
maxWidth: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Image.asset('assets/logo.png', width: 64, height: 64),
|
||||
).paddingOnly(bottom: 8, left: 4),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Image.asset('assets/logo.png', width: 64, height: 64),
|
||||
).paddingOnly(bottom: 8, left: 4),
|
||||
),
|
||||
Text(
|
||||
'signupGreeting'.tr,
|
||||
style: const TextStyle(
|
||||
@ -136,12 +141,61 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: (_) => performAction(context),
|
||||
onSubmitted: (_) => _performAction(context),
|
||||
),
|
||||
const Gap(8),
|
||||
CheckboxListTile(
|
||||
value: _isTermAccepted,
|
||||
title: Text(
|
||||
'termAccept'.tr,
|
||||
style: const TextStyle(height: 1.2),
|
||||
).paddingOnly(bottom: 4),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
),
|
||||
subtitle: RichText(
|
||||
text: TextSpan(
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.75),
|
||||
),
|
||||
children: [
|
||||
TextSpan(text: 'termAcceptDesc'.tr),
|
||||
WidgetSpan(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('termAcceptLink'.tr),
|
||||
const Gap(4),
|
||||
const Icon(Icons.launch, size: 14),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
launchUrlString('https://solsynth.dev/terms');
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => _isTermAccepted = value ?? false);
|
||||
},
|
||||
),
|
||||
const Gap(16),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed:
|
||||
!_isTermAccepted ? null : () => _performAction(context),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@ -149,12 +203,11 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
onPressed: () => performAction(context),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).paddingAll(24),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/chat/call/call_controls.dart';
|
||||
import 'package:solian/widgets/chat/call/call_participant.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as livekit;
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class CallScreen extends StatefulWidget {
|
||||
final bool hideAppBar;
|
||||
@ -197,8 +198,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
Widget build(BuildContext context) {
|
||||
final ChatCallProvider ctrl = Get.find();
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: widget.hideAppBar
|
||||
? null
|
||||
|
@ -3,6 +3,7 @@ import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:solian/controllers/chat_events_controller.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/call.dart';
|
||||
@ -25,6 +26,7 @@ import 'package:solian/widgets/chat/chat_event_list.dart';
|
||||
import 'package:solian/widgets/chat/chat_message_input.dart';
|
||||
import 'package:solian/widgets/chat/chat_typing_indicator.dart';
|
||||
import 'package:solian/widgets/current_state_action.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class ChannelChatScreen extends StatefulWidget {
|
||||
final String alias;
|
||||
@ -179,6 +181,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
|
||||
}
|
||||
}
|
||||
|
||||
late SharedPreferences _prefs;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -189,10 +193,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
|
||||
_chatController = ChatEventController();
|
||||
_chatController.initialize();
|
||||
|
||||
_getOngoingCall();
|
||||
_getChannel().then((_) {
|
||||
_chatController.getInitialEvents(_channel!, widget.realm);
|
||||
_listenMessages();
|
||||
SharedPreferences.getInstance().then((inst) {
|
||||
_prefs = inst;
|
||||
_getOngoingCall();
|
||||
_getChannel().then((_) {
|
||||
_chatController.getInitialEvents(_channel!, widget.realm);
|
||||
_listenMessages();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -201,151 +208,159 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
|
||||
String title = _channel?.name ?? 'loading'.tr;
|
||||
String? placeholder;
|
||||
|
||||
if (_channel?.type == 1) {
|
||||
final otherside =
|
||||
_channel!.members!.where((e) => e.account.id != _accountId).first;
|
||||
final otherside =
|
||||
_channel?.members!.where((e) => e.account.id != _accountId).firstOrNull;
|
||||
|
||||
if (_channel?.type == 1 && otherside != null) {
|
||||
title = otherside.account.nick;
|
||||
placeholder = 'messageInputPlaceholder'.trParams(
|
||||
{'channel': '@${otherside.account.name}'},
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
title: AppBarTitle(title),
|
||||
centerTitle: false,
|
||||
titleSpacing: AppTheme.titleSpacing(context),
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
actions: [
|
||||
const BackgroundStateWidget(),
|
||||
Builder(builder: (context) {
|
||||
if (_isBusy || _channel == null) return const SizedBox.shrink();
|
||||
return RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
title: AppBarTitle(title),
|
||||
centerTitle: false,
|
||||
titleSpacing: AppTheme.titleSpacing(context),
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
actions: [
|
||||
const BackgroundStateWidget(),
|
||||
Builder(builder: (context) {
|
||||
if (_isBusy || _channel == null) return const SizedBox.shrink();
|
||||
|
||||
return ChatCallButton(
|
||||
realm: _channel!.realm,
|
||||
channel: _channel!,
|
||||
ongoingCall: _ongoingCall,
|
||||
);
|
||||
}),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
if (_channel == null) return;
|
||||
return ChatCallButton(
|
||||
realm: _channel!.realm,
|
||||
channel: _channel!,
|
||||
ongoingCall: _ongoingCall,
|
||||
);
|
||||
}),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onPressed: () {
|
||||
if (_channel == null) return;
|
||||
|
||||
AppRouter.instance
|
||||
.pushNamed(
|
||||
'channelDetail',
|
||||
pathParameters: {'alias': widget.alias},
|
||||
queryParameters: {'realm': widget.realm},
|
||||
extra: ChannelDetailArguments(
|
||||
profile: _channelProfile!,
|
||||
channel: _channel!,
|
||||
),
|
||||
)
|
||||
.then((value) {
|
||||
if (value == false) AppRouter.instance.pop();
|
||||
if (value != null) {
|
||||
final resp = Channel.fromJson(value as Map<String, dynamic>);
|
||||
_getChannel(alias: resp.alias);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
width: AppTheme.isLargeScreen(context) ? 8 : 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Builder(builder: (context) {
|
||||
if (_isBusy || _channel == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
if (_ongoingCall != null)
|
||||
ChannelCallIndicator(
|
||||
channel: _channel!,
|
||||
ongoingCall: _ongoingCall!,
|
||||
onJoin: () {
|
||||
if (!AppTheme.isLargeScreen(context)) {
|
||||
final ChatCallProvider call = Get.find();
|
||||
call.gotoScreen(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: ChatEventList(
|
||||
scope: widget.realm,
|
||||
channel: _channel!,
|
||||
chatController: _chatController,
|
||||
onEdit: (item) {
|
||||
setState(() => _messageToEditing = item);
|
||||
},
|
||||
onReply: (item) {
|
||||
setState(() => _messageToReplying = item);
|
||||
},
|
||||
),
|
||||
AppRouter.instance
|
||||
.pushNamed(
|
||||
'channelDetail',
|
||||
pathParameters: {'alias': widget.alias},
|
||||
queryParameters: {'realm': widget.realm},
|
||||
extra: ChannelDetailArguments(
|
||||
profile: _channelProfile!,
|
||||
channel: _channel!,
|
||||
),
|
||||
ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
ChatTypingIndicator(users: _typingUsers),
|
||||
ChatMessageInput(
|
||||
edit: _messageToEditing,
|
||||
reply: _messageToReplying,
|
||||
realm: widget.realm,
|
||||
placeholder: placeholder,
|
||||
channel: _channel!,
|
||||
onSent: (Event item) {
|
||||
setState(() {
|
||||
_chatController.addPendingEvent(item);
|
||||
});
|
||||
},
|
||||
onReset: () {
|
||||
setState(() {
|
||||
_messageToReplying = null;
|
||||
_messageToEditing = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
.then((value) {
|
||||
if (value == false) AppRouter.instance.pop();
|
||||
if (value != null) {
|
||||
final resp =
|
||||
Channel.fromJson(value as Map<String, dynamic>);
|
||||
_getChannel(alias: resp.alias);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
width: AppTheme.isLargeScreen(context) ? 8 : 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Builder(builder: (context) {
|
||||
if (_isBusy || _channel == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
if (_ongoingCall != null)
|
||||
ChannelCallIndicator(
|
||||
channel: _channel!,
|
||||
ongoingCall: _ongoingCall!,
|
||||
onJoin: () {
|
||||
if (!AppTheme.isUltraLargeScreen(context)) {
|
||||
final ChatCallProvider call = Get.find();
|
||||
call.gotoScreen(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: ChatEventList(
|
||||
noAnimated:
|
||||
_prefs.getBool('non_animated_message_list') ??
|
||||
false,
|
||||
scope: widget.realm,
|
||||
channel: _channel!,
|
||||
chatController: _chatController,
|
||||
onEdit: (item) {
|
||||
setState(() => _messageToEditing = item);
|
||||
},
|
||||
onReply: (item) {
|
||||
setState(() => _messageToReplying = item);
|
||||
},
|
||||
),
|
||||
),
|
||||
ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
ChatTypingIndicator(users: _typingUsers),
|
||||
ChatMessageInput(
|
||||
edit: _messageToEditing,
|
||||
reply: _messageToReplying,
|
||||
realm: widget.realm,
|
||||
placeholder: placeholder,
|
||||
channel: _channel!,
|
||||
onSent: (Event item) {
|
||||
setState(() {
|
||||
_chatController.addPendingEvent(item);
|
||||
});
|
||||
},
|
||||
onReset: () {
|
||||
setState(() {
|
||||
_messageToReplying = null;
|
||||
_messageToEditing = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Obx(() {
|
||||
final ChatCallProvider call = Get.find();
|
||||
if (call.isMounted.value && AppTheme.isLargeScreen(context)) {
|
||||
return const Expanded(
|
||||
child: Row(children: [
|
||||
VerticalDivider(width: 0.3, thickness: 0.3),
|
||||
Expanded(
|
||||
child: CallScreen(
|
||||
hideAppBar: true,
|
||||
isExpandable: true,
|
||||
Obx(() {
|
||||
final ChatCallProvider call = Get.find();
|
||||
if (call.isMounted.value &&
|
||||
AppTheme.isUltraLargeScreen(context)) {
|
||||
return const Expanded(
|
||||
child: Row(children: [
|
||||
VerticalDivider(width: 0.3, thickness: 0.3),
|
||||
Expanded(
|
||||
child: CallScreen(
|
||||
hideAppBar: true,
|
||||
isExpandable: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}),
|
||||
],
|
||||
);
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,7 @@ import 'package:solian/providers/content/channel.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChannelOrganizeArguments {
|
||||
@ -114,8 +115,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
|
||||
),
|
||||
];
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AppBarTitle('channelOrganizing'.tr),
|
||||
|
@ -1,145 +1,326 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/controllers/chat_events_controller.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/channel.dart';
|
||||
import 'package:solian/providers/content/realm.dart';
|
||||
import 'package:solian/providers/database/database.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/screens/account/notification.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/account/signin_required_overlay.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/channel/channel_list.dart';
|
||||
import 'package:solian/widgets/chat/call/chat_call_indicator.dart';
|
||||
import 'package:solian/widgets/current_state_action.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
import 'package:solian/widgets/sidebar/empty_placeholder.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
class ChatScreen extends StatelessWidget {
|
||||
const ChatScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
Widget build(BuildContext context) {
|
||||
return const RootContainer(
|
||||
child: ChatList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
late final ChannelProvider _channels;
|
||||
class ChatListShell extends StatelessWidget {
|
||||
final Widget? child;
|
||||
|
||||
const ChatListShell({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RootContainer(
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 360,
|
||||
child: ChatList(),
|
||||
),
|
||||
const VerticalDivider(thickness: 0.3, width: 0.3),
|
||||
Expanded(child: child ?? const EmptyPagePlaceholder()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatList extends StatefulWidget {
|
||||
const ChatList({super.key});
|
||||
|
||||
@override
|
||||
State<ChatList> createState() => _ChatListState();
|
||||
}
|
||||
|
||||
class _ChatListState extends State<ChatList> {
|
||||
List<Channel> _normalChannels = List.empty();
|
||||
List<Channel> _directChannels = List.empty();
|
||||
final Map<String, List<Channel>> _realmChannels = {};
|
||||
|
||||
late final ChannelProvider _channels = Get.find();
|
||||
|
||||
List<Channel> _sortChannels(List<Channel> channels) {
|
||||
channels.sort(
|
||||
(a, b) =>
|
||||
_lastMessages?[b.id]?.createdAt.compareTo(
|
||||
_lastMessages?[a.id]?.createdAt ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
) ??
|
||||
0,
|
||||
);
|
||||
return channels;
|
||||
}
|
||||
|
||||
Future<void> _loadNormalChannels() async {
|
||||
final resp = await _channels.listAvailableChannel(isDirect: false);
|
||||
setState(() {
|
||||
_normalChannels = _sortChannels(resp);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadDirectChannels() async {
|
||||
final resp = await _channels.listAvailableChannel(isDirect: true);
|
||||
setState(() {
|
||||
_directChannels = _sortChannels(resp);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadRealmChannels(String realm) async {
|
||||
final resp = await _channels.listAvailableChannel(scope: realm);
|
||||
setState(() {
|
||||
_realmChannels[realm] = _sortChannels(List.from(resp));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAllChannels() async {
|
||||
final RealmProvider realms = Get.find();
|
||||
Future.wait([
|
||||
_loadNormalChannels(),
|
||||
_loadDirectChannels(),
|
||||
...realms.availableRealms.map((x) => _loadRealmChannels(x.alias)),
|
||||
]);
|
||||
}
|
||||
|
||||
Map<int, LocalMessageEventTableData>? _lastMessages;
|
||||
|
||||
Future<void> _loadLastMessages() async {
|
||||
final ctrl = ChatEventController();
|
||||
await ctrl.initialize();
|
||||
final messages = await ctrl.src.getLastInAllChannels();
|
||||
setState(() {
|
||||
_lastMessages = messages
|
||||
.map((k, v) => MapEntry(k, v.firstOrNull))
|
||||
.cast<int, LocalMessageEventTableData>();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
try {
|
||||
_channels = Get.find();
|
||||
_channels.refreshAvailableChannel();
|
||||
} catch (e) {
|
||||
context.showErrorDialog(e);
|
||||
}
|
||||
_loadLastMessages().then((_) {
|
||||
_loadAllChannels();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AuthProvider auth = Get.find();
|
||||
final RealmProvider realms = Get.find();
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
title: AppBarTitle('chat'.tr),
|
||||
centerTitle: true,
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
actions: [
|
||||
const BackgroundStateWidget(),
|
||||
const NotificationButton(),
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Icons.add_circle),
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text('channelOrganizeCommon'.tr),
|
||||
leading: const Icon(Icons.tag),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onTap: () {
|
||||
AppRouter.instance.pushNamed('channelOrganizing').then(
|
||||
(value) {
|
||||
if (value != null) {
|
||||
_channels.refreshAvailableChannel();
|
||||
}
|
||||
return Obx(
|
||||
() => DefaultTabController(
|
||||
length: 2 + realms.availableRealms.length,
|
||||
child: RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: Obx(() {
|
||||
final adaptive = AppBarLeadingButton.adaptive(context);
|
||||
if (adaptive != null) return adaptive;
|
||||
if (_channels.isLoading.value) {
|
||||
return const CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
).paddingAll(18);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}),
|
||||
title: AppBarTitle('chat'.tr),
|
||||
centerTitle: true,
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
actions: [
|
||||
const BackgroundStateWidget(),
|
||||
const NotificationButton(),
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Icons.add_circle),
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text('channelOrganizeCommon'.tr),
|
||||
leading: const Icon(Icons.tag),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onTap: () {
|
||||
AppRouter.instance.pushNamed('channelOrganizing').then(
|
||||
(value) {
|
||||
if (value != null) {
|
||||
_loadAllChannels();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text('channelOrganizeDirect'.tr),
|
||||
leading: const FaIcon(
|
||||
FontAwesomeIcons.userGroup,
|
||||
size: 16,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onTap: () {
|
||||
final ChannelProvider channels = Get.find();
|
||||
channels
|
||||
.createDirectChannel(context, 'global')
|
||||
.then((resp) {
|
||||
if (resp != null) {
|
||||
_channels.refreshAvailableChannel();
|
||||
}
|
||||
}).catchError((e) {
|
||||
context.showErrorDialog(e);
|
||||
});
|
||||
},
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text('channelOrganizeDirect'.tr),
|
||||
leading: const FaIcon(
|
||||
FontAwesomeIcons.userGroup,
|
||||
size: 16,
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onTap: () {
|
||||
final ChannelProvider channels = Get.find();
|
||||
channels
|
||||
.createDirectChannel(context, 'global')
|
||||
.then((resp) {
|
||||
if (resp != null) {
|
||||
_loadAllChannels();
|
||||
}
|
||||
}).catchError((e) {
|
||||
context.showErrorDialog(e);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: AppTheme.isLargeScreen(context) ? 8 : 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: AppTheme.isLargeScreen(context) ? 8 : 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Obx(() {
|
||||
if (auth.isAuthorized.isFalse) {
|
||||
return SigninRequiredOverlay(
|
||||
onDone: () => _channels.refreshAvailableChannel(),
|
||||
);
|
||||
}
|
||||
|
||||
final selfId = auth.userProfile.value!['id'];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Obx(() {
|
||||
if (_channels.isLoading.isFalse) {
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
return const LinearProgressIndicator();
|
||||
}
|
||||
}),
|
||||
const ChatCallCurrentIndicator(),
|
||||
Expanded(
|
||||
child: CenteredContainer(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _channels.refreshAvailableChannel,
|
||||
child: Obx(
|
||||
() => ChannelListWidget(
|
||||
noCategory: true,
|
||||
channels: List.from([
|
||||
..._channels.groupChannels
|
||||
.where((x) => x.realmId == null),
|
||||
..._channels.directChannels
|
||||
]),
|
||||
selfId: selfId,
|
||||
useReplace: true,
|
||||
),
|
||||
bottom: TabBar(
|
||||
isScrollable: true,
|
||||
dividerHeight: 0.3,
|
||||
tabAlignment: TabAlignment.startOffset,
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
child: const Icon(
|
||||
Icons.forum,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text('all'.tr),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 14,
|
||||
child: Icon(
|
||||
Icons.chat_bubble,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text('channelTypeDirect'.tr),
|
||||
],
|
||||
),
|
||||
),
|
||||
...realms.availableRealms.map((x) => Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AccountAvatar(
|
||||
content: x.avatar,
|
||||
radius: 14,
|
||||
fallbackWidget: const Icon(
|
||||
Icons.workspaces,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(x.name),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
body: Obx(() {
|
||||
if (auth.isAuthorized.isFalse) {
|
||||
return SigninRequiredOverlay(
|
||||
onDone: () => _loadAllChannels(),
|
||||
);
|
||||
}
|
||||
|
||||
final selfId = auth.userProfile.value!['id'];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const ChatCallCurrentIndicator(),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
RefreshIndicator(
|
||||
onRefresh: _loadNormalChannels,
|
||||
child: ChannelListWidget(
|
||||
channels: _sortChannels([
|
||||
..._normalChannels,
|
||||
..._directChannels,
|
||||
..._realmChannels.values.expand((x) => x),
|
||||
]),
|
||||
selfId: selfId,
|
||||
useReplace: AppTheme.isLargeScreen(context),
|
||||
),
|
||||
),
|
||||
RefreshIndicator(
|
||||
onRefresh: _loadDirectChannels,
|
||||
child: ChannelListWidget(
|
||||
channels: _directChannels,
|
||||
selfId: selfId,
|
||||
useReplace: AppTheme.isLargeScreen(context),
|
||||
),
|
||||
),
|
||||
...realms.availableRealms.map(
|
||||
(x) => RefreshIndicator(
|
||||
onRefresh: () => _loadRealmChannels(x.alias),
|
||||
child: ChannelListWidget(
|
||||
channels: _realmChannels[x.alias] ?? [],
|
||||
selfId: selfId,
|
||||
useReplace: AppTheme.isLargeScreen(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -88,7 +88,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
Future<void> _pullDaily() async {
|
||||
try {
|
||||
_signRecord = await _dailySign.getToday();
|
||||
_dailySign.listLastRecord(30).then((value) {
|
||||
_dailySign.listLastRecord(14).then((value) {
|
||||
setState(() => _signRecordHistory = value);
|
||||
});
|
||||
} catch (e) {
|
||||
@ -103,7 +103,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
|
||||
try {
|
||||
_signRecord = await _dailySign.signToday();
|
||||
_dailySign.listLastRecord(30).then((value) {
|
||||
_dailySign.listLastRecord(14).then((value) {
|
||||
setState(() => _signRecordHistory = value);
|
||||
});
|
||||
} catch (e) {
|
||||
@ -354,7 +354,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
onPressed: () {
|
||||
AppRouter.instance.goNamed('feed');
|
||||
AppRouter.instance.goNamed('explore');
|
||||
},
|
||||
),
|
||||
],
|
||||
|
@ -10,20 +10,21 @@ import 'package:solian/router.dart';
|
||||
import 'package:solian/screens/account/notification.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/account/signin_required_overlay.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/current_state_action.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/navigation/realm_switcher.dart';
|
||||
import 'package:solian/widgets/posts/post_shuffle_swiper.dart';
|
||||
import 'package:solian/widgets/posts/post_warped_list.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class FeedScreen extends StatefulWidget {
|
||||
const FeedScreen({super.key});
|
||||
class ExploreScreen extends StatefulWidget {
|
||||
const ExploreScreen({super.key});
|
||||
|
||||
@override
|
||||
State<FeedScreen> createState() => _FeedScreenState();
|
||||
State<ExploreScreen> createState() => _ExploreScreenState();
|
||||
}
|
||||
|
||||
class _FeedScreenState extends State<FeedScreen>
|
||||
class _ExploreScreenState extends State<ExploreScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final PostListController _postController;
|
||||
late final TabController _tabController;
|
||||
@ -55,10 +56,8 @@ class _FeedScreenState extends State<FeedScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AuthProvider auth = Get.find();
|
||||
final NavigationStateProvider navState = Get.find();
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: Scaffold(
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
@ -82,8 +81,14 @@ class _FeedScreenState extends State<FeedScreen>
|
||||
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
|
||||
return [
|
||||
SliverAppBar(
|
||||
title: AppBarTitle('feed'.tr),
|
||||
centerTitle: false,
|
||||
flexibleSpace: SizedBox(
|
||||
height: 48,
|
||||
child: const Row(
|
||||
children: [
|
||||
RealmSwitcher(),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 8),
|
||||
).paddingOnly(top: MediaQuery.of(context).padding.top),
|
||||
floating: true,
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
@ -96,10 +101,39 @@ class _FeedScreenState extends State<FeedScreen>
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
dividerHeight: 0.3,
|
||||
tabAlignment: TabAlignment.fill,
|
||||
tabs: [
|
||||
Tab(text: 'postListNews'.tr),
|
||||
Tab(text: 'postListFriends'.tr),
|
||||
Tab(text: 'postListShuffle'.tr),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.feed, size: 20),
|
||||
const Gap(8),
|
||||
Text('postListNews'.tr),
|
||||
],
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.people, size: 20),
|
||||
const Gap(8),
|
||||
Text('postListFriends'.tr),
|
||||
],
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.shuffle_on_outlined, size: 20),
|
||||
const Gap(8),
|
||||
Text('postListShuffle'.tr),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@ -114,16 +148,6 @@ class _FeedScreenState extends State<FeedScreen>
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (navState.focusedRealm.value != null)
|
||||
MaterialBanner(
|
||||
leading: const Icon(Icons.layers),
|
||||
content: Text(
|
||||
'postBrowsingIn'.trParams({
|
||||
'region': '#${navState.focusedRealm.value!.alias}',
|
||||
}),
|
||||
),
|
||||
actions: const [SizedBox.shrink()],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
@ -9,6 +9,7 @@ import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/posts/post_action.dart';
|
||||
import 'package:solian/widgets/posts/post_owned_list.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class DraftBoxScreen extends StatefulWidget {
|
||||
const DraftBoxScreen({super.key});
|
||||
@ -54,8 +55,7 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
|
@ -63,13 +63,13 @@ class _FeedSearchScreenState extends State<FeedSearchScreen> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.label),
|
||||
tileColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
title: Text('feedSearchWithTag'.trParams({'key': widget.tag!})),
|
||||
title: Text('postSearchWithTag'.trParams({'key': widget.tag!})),
|
||||
),
|
||||
if (widget.category != null)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.category),
|
||||
tileColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
title: Text('feedSearchWithCategory'
|
||||
title: Text('postSearchWithCategory'
|
||||
.trParams({'key': widget.category!})),
|
||||
),
|
||||
Expanded(
|
||||
|
@ -6,6 +6,7 @@ import 'package:solian/providers/content/posts.dart';
|
||||
import 'package:solian/providers/last_read.dart';
|
||||
import 'package:solian/widgets/posts/post_item.dart';
|
||||
import 'package:solian/widgets/posts/post_replies.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class PostDetailScreen extends StatefulWidget {
|
||||
final String id;
|
||||
@ -47,8 +48,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: FutureBuilder(
|
||||
future: getDetail(),
|
||||
builder: (context, snapshot) {
|
||||
|
@ -19,6 +19,7 @@ import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/markdown_text_content.dart';
|
||||
import 'package:solian/widgets/posts/post_item.dart';
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class PostPublishArguments {
|
||||
final Post? edit;
|
||||
@ -151,8 +152,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
|
||||
)
|
||||
];
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
@ -376,6 +376,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: MarkdownTextContent(
|
||||
isAutoWarp: _editorController.mode.value == 0,
|
||||
content: _editorController.contentController.text,
|
||||
parentId: 'post-editor-preview',
|
||||
).paddingOnly(top: 12, right: 16),
|
||||
|
@ -15,6 +15,7 @@ import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/auto_cache_image.dart';
|
||||
import 'package:solian/widgets/current_state_action.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
|
||||
class RealmListScreen extends StatefulWidget {
|
||||
@ -58,8 +59,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final AuthProvider auth = Get.find();
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
|
@ -7,6 +7,7 @@ import 'package:solian/router.dart';
|
||||
import 'package:solian/screens/realms/realm_organize.dart';
|
||||
import 'package:solian/widgets/realms/realm_deletion.dart';
|
||||
import 'package:solian/widgets/realms/realm_member.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class RealmDetailScreen extends StatefulWidget {
|
||||
final String alias;
|
||||
@ -86,61 +87,63 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
||||
),
|
||||
];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: Colors.teal,
|
||||
child: Icon(Icons.group, color: Colors.white),
|
||||
),
|
||||
const Gap(16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.realm.name,
|
||||
style: Theme.of(context).textTheme.bodyLarge),
|
||||
Text(widget.realm.description,
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
Text(
|
||||
'#${widget.realm.id.toString().padLeft(8, '0')} · ${widget.realm.alias}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
return RootContainer(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: Colors.teal,
|
||||
child: Icon(Icons.group, color: Colors.white),
|
||||
),
|
||||
)
|
||||
],
|
||||
const Gap(16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.realm.name,
|
||||
style: Theme.of(context).textTheme.bodyLarge),
|
||||
Text(widget.realm.description,
|
||||
style: Theme.of(context).textTheme.bodySmall),
|
||||
Text(
|
||||
'#${widget.realm.id.toString().padLeft(8, '0')} · ${widget.realm.alias}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(thickness: 0.3),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Icons.supervisor_account),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
title: Text('realmMembers'.tr),
|
||||
onTap: () => showMemberList(),
|
||||
),
|
||||
...(_isOwned ? ownerActions : List.empty()),
|
||||
const Divider(thickness: 0.3),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: _isOwned
|
||||
? const Icon(Icons.delete)
|
||||
: const Icon(Icons.exit_to_app),
|
||||
title: Text(_isOwned ? 'delete'.tr : 'leave'.tr),
|
||||
onTap: () => promptLeaveChannel(),
|
||||
),
|
||||
],
|
||||
const Divider(thickness: 0.3),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Icons.supervisor_account),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
title: Text('realmMembers'.tr),
|
||||
onTap: () => showMemberList(),
|
||||
),
|
||||
...(_isOwned ? ownerActions : List.empty()),
|
||||
const Divider(thickness: 0.3),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: _isOwned
|
||||
? const Icon(Icons.delete)
|
||||
: const Icon(Icons.exit_to_app),
|
||||
title: Text(_isOwned ? 'delete'.tr : 'leave'.tr),
|
||||
onTap: () => promptLeaveChannel(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,3 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:get/get.dart';
|
||||
@ -8,12 +6,14 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/attachment.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/attachment.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class RealmOrganizeArguments {
|
||||
@ -84,36 +84,42 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (auth.isAuthorized.isFalse) return;
|
||||
|
||||
XFile file;
|
||||
|
||||
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
|
||||
if (image == null) return;
|
||||
|
||||
CroppedFile? croppedFile = await ImageCropper().cropImage(
|
||||
sourcePath: image.path,
|
||||
uiSettings: [
|
||||
AndroidUiSettings(
|
||||
toolbarTitle: 'cropImage'.tr,
|
||||
toolbarColor: Theme.of(context).colorScheme.primary,
|
||||
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
IOSUiSettings(
|
||||
title: 'cropImage'.tr,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
WebUiSettings(
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (PlatformInfo.canCropImage) {
|
||||
CroppedFile? croppedFile = await ImageCropper().cropImage(
|
||||
sourcePath: image.path,
|
||||
uiSettings: [
|
||||
AndroidUiSettings(
|
||||
toolbarTitle: 'cropImage'.tr,
|
||||
toolbarColor: Theme.of(context).colorScheme.primary,
|
||||
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
IOSUiSettings(
|
||||
title: 'cropImage'.tr,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
WebUiSettings(
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (croppedFile == null) return;
|
||||
final file = File(croppedFile.path);
|
||||
if (croppedFile == null) return;
|
||||
file = XFile(croppedFile.path);
|
||||
} else {
|
||||
file = XFile(image.path);
|
||||
}
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
@ -184,8 +190,7 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
|
@ -16,6 +16,7 @@ import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/channel/channel_list.dart';
|
||||
import 'package:solian/widgets/posts/post_list.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class RealmViewScreen extends StatefulWidget {
|
||||
final String alias;
|
||||
@ -68,12 +69,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
|
||||
_channels.addAll(
|
||||
resp.body.map((e) => Channel.fromJson(e)).toList().cast<Channel>(),
|
||||
);
|
||||
_channels.addAll(
|
||||
availableResp.body
|
||||
.map((e) => Channel.fromJson(e))
|
||||
.toList()
|
||||
.cast<Channel>(),
|
||||
);
|
||||
_channels.addAll(availableResp);
|
||||
_channels.retainWhere((x) => channelIdx.add(x.id));
|
||||
});
|
||||
|
||||
@ -91,8 +87,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: DefaultTabController(
|
||||
length: 2,
|
||||
child: NestedScrollView(
|
||||
@ -260,7 +255,6 @@ class RealmChannelListWidget extends StatelessWidget {
|
||||
child: ChannelListWidget(
|
||||
channels: channels,
|
||||
selfId: auth.userProfile.value!['id'],
|
||||
noCategory: true,
|
||||
),
|
||||
)
|
||||
],
|
||||
|
@ -1,13 +1,25 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:solian/exceptions/request.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/theme.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/database/database.dart';
|
||||
import 'package:solian/providers/theme_switcher.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/reports/abuse_report.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class SettingScreen extends StatefulWidget {
|
||||
const SettingScreen({super.key});
|
||||
@ -18,6 +30,7 @@ class SettingScreen extends StatefulWidget {
|
||||
|
||||
class _SettingScreenState extends State<SettingScreen> {
|
||||
SharedPreferences? _prefs;
|
||||
String _docBasepath = '/';
|
||||
|
||||
Widget _buildCaptionHeader(String title) {
|
||||
return Container(
|
||||
@ -28,39 +41,38 @@ class _SettingScreenState extends State<SettingScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThemeColorButton(String label, Color color) {
|
||||
return IconButton(
|
||||
icon: Icon(Icons.circle, color: color),
|
||||
tooltip: label,
|
||||
onPressed: () {
|
||||
context.read<ThemeSwitcher>().setTheme(
|
||||
AppTheme.build(
|
||||
Brightness.light,
|
||||
seedColor: color,
|
||||
),
|
||||
AppTheme.build(
|
||||
Brightness.dark,
|
||||
seedColor: color,
|
||||
),
|
||||
);
|
||||
_prefs?.setInt('global_theme_color', color.value);
|
||||
context.clearSnackbar();
|
||||
context.showSnackbar('themeColorApplied'.tr);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static final List<(String, Color)> _presentTheme = [
|
||||
('themeColorRed', const Color.fromRGBO(154, 98, 91, 1)),
|
||||
('themeColorBlue', const Color.fromRGBO(103, 96, 193, 1)),
|
||||
('themeColorMiku', const Color.fromRGBO(56, 120, 126, 1)),
|
||||
('themeColorKagamine', const Color.fromRGBO(244, 183, 63, 1)),
|
||||
('themeColorLuka', const Color.fromRGBO(243, 174, 218, 1)),
|
||||
static final List<SolianThemeData> _presentTheme = [
|
||||
SolianThemeData(
|
||||
id: 'themeColorRed',
|
||||
seedColor: const Color.fromRGBO(154, 98, 91, 1),
|
||||
),
|
||||
SolianThemeData(
|
||||
id: 'themeColorBlue',
|
||||
seedColor: const Color.fromRGBO(103, 96, 193, 1),
|
||||
),
|
||||
SolianThemeData(
|
||||
id: 'themeColorMiku',
|
||||
seedColor: const Color.fromRGBO(56, 120, 126, 1),
|
||||
),
|
||||
SolianThemeData(
|
||||
id: 'themeColorKagamine',
|
||||
seedColor: const Color.fromRGBO(244, 183, 63, 1),
|
||||
),
|
||||
SolianThemeData(
|
||||
id: 'themeColorLuka',
|
||||
seedColor: const Color.fromRGBO(243, 174, 218, 1),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
getApplicationDocumentsDirectory().then((dir) {
|
||||
_docBasepath = dir.path;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
SharedPreferences.getInstance().then((inst) {
|
||||
_prefs = inst;
|
||||
if (mounted) {
|
||||
@ -71,20 +83,101 @@ class _SettingScreenState extends State<SettingScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: ListView(
|
||||
children: [
|
||||
_buildCaptionHeader('themeColor'.tr),
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: _presentTheme
|
||||
.map((x) => _buildThemeColorButton(x.$1, x.$2))
|
||||
.toList(),
|
||||
).paddingSymmetric(horizontal: 12, vertical: 8),
|
||||
_buildCaptionHeader('theme'.tr),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
|
||||
title: Text('globalTheme'.tr),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<SolianThemeData>(
|
||||
isExpanded: true,
|
||||
hint: Text(
|
||||
'theme'.tr,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
items: _presentTheme
|
||||
.map((SolianThemeData item) =>
|
||||
DropdownMenuItem<SolianThemeData>(
|
||||
value: item,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.circle, color: item.seedColor),
|
||||
const Gap(8),
|
||||
Text(
|
||||
item.id.tr,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
value: (_prefs?.containsKey('global_theme') ?? false)
|
||||
? SolianThemeData.fromJson(
|
||||
jsonDecode(_prefs!.getString('global_theme')!),
|
||||
)
|
||||
: null,
|
||||
onChanged: (SolianThemeData? value) {
|
||||
context.read<ThemeSwitcher>().setThemeData(value);
|
||||
setState(() {});
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
height: 40,
|
||||
width: 140,
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
secondary: const Icon(Icons.military_tech),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
|
||||
title: Text('agedTheme'.tr),
|
||||
subtitle: Text('agedThemeDesc'.tr),
|
||||
value: _prefs?.getBool('aged_theme') ?? false,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
context.read<ThemeSwitcher>().setAgedTheme(value);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
if (!PlatformInfo.isWeb)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wallpaper),
|
||||
contentPadding: const EdgeInsets.only(left: 22, right: 31),
|
||||
title: Text('appBackgroundImage'.tr),
|
||||
subtitle: Text('appBackgroundImageDesc'.tr),
|
||||
trailing: File('$_docBasepath/app_background_image').existsSync()
|
||||
? const Icon(Icons.check_box)
|
||||
: const Icon(Icons.check_box_outline_blank),
|
||||
onTap: () async {
|
||||
if (File('$_docBasepath/app_background_image').existsSync()) {
|
||||
File('$_docBasepath/app_background_image').deleteSync();
|
||||
} else {
|
||||
final image = await ImagePicker().pickImage(
|
||||
source: ImageSource.gallery,
|
||||
);
|
||||
if (image == null) return;
|
||||
|
||||
await File(image.path)
|
||||
.copy('$_docBasepath/app_background_image');
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
_buildCaptionHeader('notification'.tr),
|
||||
Tooltip(
|
||||
message: 'settingsNotificationBgServiceDesc'.tr,
|
||||
@ -129,6 +222,69 @@ class _SettingScreenState extends State<SettingScreen> {
|
||||
});
|
||||
},
|
||||
),
|
||||
Obx(() {
|
||||
final AuthProvider auth = Get.find<AuthProvider>();
|
||||
if (!auth.isAuthorized.value) return const SizedBox.shrink();
|
||||
return Column(
|
||||
children: [
|
||||
_buildCaptionHeader('account'.tr),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.flag),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
|
||||
title: Text('reportAbuse'.tr),
|
||||
subtitle: Text('reportAbuseDesc'.tr),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => const AbuseReportDialog(),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_remove),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
|
||||
title: Text('accountDeletion'.tr),
|
||||
subtitle: Text('accountDeletionDesc'.tr),
|
||||
onTap: () {
|
||||
context
|
||||
.showSlideToConfirmDialog(
|
||||
'accountDeletionConfirm'.tr,
|
||||
'accountDeletionConfirmDesc'.trParams({
|
||||
'account': '@${auth.userProfile.value!['name']}',
|
||||
}),
|
||||
)
|
||||
.then((value) async {
|
||||
if (value != true) return;
|
||||
final client = await auth.configureClient('id');
|
||||
final resp = await client.post('/users/me/deletion', {});
|
||||
if (resp.statusCode != 200) {
|
||||
context.showErrorDialog(RequestException(resp));
|
||||
} else {
|
||||
context.showSnackbar('accountDeletionRequested'.tr);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
_buildCaptionHeader('performance'.tr),
|
||||
CheckboxListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
|
||||
secondary: const Icon(Icons.message),
|
||||
title: Text('animatedMessageList'.tr),
|
||||
subtitle: Text('animatedMessageListDesc'.tr),
|
||||
value: _prefs?.getBool('non_animated_message_list') ?? false,
|
||||
onChanged: (value) {
|
||||
_prefs
|
||||
?.setBool('non_animated_message_list', value ?? false)
|
||||
.then((_) {
|
||||
setState(() {});
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildCaptionHeader('more'.tr),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_sweep),
|
||||
@ -154,6 +310,21 @@ class _SettingScreenState extends State<SettingScreen> {
|
||||
});
|
||||
},
|
||||
),
|
||||
if (PlatformInfo.canRateTheApp)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.star),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
|
||||
title: Text('rateTheApp'.tr),
|
||||
subtitle: Text('rateTheAppDesc'.tr),
|
||||
onTap: () {
|
||||
final inAppReview = InAppReview.instance;
|
||||
|
||||
inAppReview.openStoreListing(
|
||||
appStoreId: '6499032345',
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
|
@ -2,7 +2,9 @@ import 'package:firebase_analytics/firebase_analytics.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/navigation/app_navigation_drawer.dart';
|
||||
import 'package:solian/widgets/navigation/app_navigation.dart';
|
||||
import 'package:solian/widgets/navigation/app_navigation_bottom.dart';
|
||||
import 'package:solian/widgets/navigation/app_navigation_rail.dart';
|
||||
|
||||
final GlobalKey<ScaffoldState> rootScaffoldKey = GlobalKey<ScaffoldState>();
|
||||
|
||||
@ -39,17 +41,28 @@ class RootShell extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final showRailNavigation = AppTheme.isLargeScreen(context);
|
||||
|
||||
final destNames = AppNavigation.destinations.map((x) => x.page).toList();
|
||||
final showBottomNavigation =
|
||||
destNames.contains(routeName) && !showRailNavigation;
|
||||
|
||||
return Scaffold(
|
||||
key: rootScaffoldKey,
|
||||
drawer: AppTheme.isLargeScreen(context)
|
||||
? null
|
||||
: AppNavigationDrawer(routeName: routeName),
|
||||
bottomNavigationBar: showBottomNavigation
|
||||
? AppNavigationBottom(
|
||||
initialIndex: destNames.indexOf(routeName ?? 'page'),
|
||||
)
|
||||
: null,
|
||||
body: AppTheme.isLargeScreen(context)
|
||||
? Row(
|
||||
children: [
|
||||
if (showNavigation) AppNavigationDrawer(routeName: routeName),
|
||||
if (showNavigation)
|
||||
const VerticalDivider(thickness: 0.3, width: 1),
|
||||
if (showRailNavigation) const AppNavigationRail(),
|
||||
if (showRailNavigation)
|
||||
const VerticalDivider(
|
||||
width: 0.3,
|
||||
thickness: 0.3,
|
||||
),
|
||||
Expanded(child: child),
|
||||
],
|
||||
)
|
||||
|
@ -1,62 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/sidebar/sidebar_placeholder.dart';
|
||||
|
||||
class SidebarShell extends StatelessWidget {
|
||||
final bool showAppBar;
|
||||
final GoRouterState state;
|
||||
final Widget child;
|
||||
|
||||
final bool sidebarFirst;
|
||||
final Widget? sidebar;
|
||||
|
||||
const SidebarShell({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.state,
|
||||
this.showAppBar = true,
|
||||
this.sidebarFirst = false,
|
||||
this.sidebar,
|
||||
});
|
||||
|
||||
List<Widget> buildContent(BuildContext context) {
|
||||
return [
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: child,
|
||||
),
|
||||
if (AppTheme.isExtraLargeScreen(context))
|
||||
const VerticalDivider(thickness: 0.3, width: 1),
|
||||
if (AppTheme.isExtraLargeScreen(context))
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: sidebar ?? const SidebarPlaceholder(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: showAppBar
|
||||
? AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
title: AppBarTitle(state.topRoute?.name?.tr ?? 'page'.tr),
|
||||
centerTitle: false,
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
)
|
||||
: null,
|
||||
body: AppTheme.isLargeScreen(context)
|
||||
? Row(
|
||||
children: sidebarFirst
|
||||
? buildContent(context).reversed.toList()
|
||||
: buildContent(context),
|
||||
)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/current_state_action.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class TitleShell extends StatelessWidget {
|
||||
final bool showAppBar;
|
||||
@ -26,24 +27,26 @@ class TitleShell extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
assert(state != null || title != null);
|
||||
|
||||
return Scaffold(
|
||||
appBar: showAppBar
|
||||
? AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
title: AppBarTitle(
|
||||
title ?? (state!.topRoute?.name?.tr ?? 'page'.tr),
|
||||
),
|
||||
centerTitle: isCenteredTitle,
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
actions: [
|
||||
const BackgroundStateWidget(),
|
||||
SizedBox(
|
||||
width: AppTheme.isLargeScreen(context) ? 8 : 16,
|
||||
return RootContainer(
|
||||
child: Scaffold(
|
||||
appBar: showAppBar
|
||||
? AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
title: AppBarTitle(
|
||||
title ?? (state!.topRoute?.name?.tr ?? 'page'.tr),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
body: child,
|
||||
centerTitle: isCenteredTitle,
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
actions: [
|
||||
const BackgroundStateWidget(),
|
||||
SizedBox(
|
||||
width: AppTheme.isLargeScreen(context) ? 8 : 16,
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
body: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/models/theme.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
|
||||
abstract class AppTheme {
|
||||
@ -6,7 +7,10 @@ abstract class AppTheme {
|
||||
MediaQuery.of(context).size.width > 640;
|
||||
|
||||
static bool isExtraLargeScreen(BuildContext context) =>
|
||||
MediaQuery.of(context).size.width > 720;
|
||||
MediaQuery.of(context).size.width > 920;
|
||||
|
||||
static bool isUltraLargeScreen(BuildContext context) =>
|
||||
MediaQuery.of(context).size.width > 1200;
|
||||
|
||||
static bool isSpecializedMacOS(BuildContext context) =>
|
||||
PlatformInfo.isMacOS && !AppTheme.isLargeScreen(context);
|
||||
@ -35,6 +39,10 @@ abstract class AppTheme {
|
||||
brightness: brightness,
|
||||
seedColor: seedColor ?? const Color.fromRGBO(154, 98, 91, 1),
|
||||
),
|
||||
scaffoldBackgroundColor: Colors.transparent,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
fontFamily: 'Comfortaa',
|
||||
fontFamilyFallback: [
|
||||
'NotoSansSC',
|
||||
@ -49,4 +57,36 @@ abstract class AppTheme {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData buildFromData(
|
||||
Brightness brightness,
|
||||
SolianThemeData data, {
|
||||
bool useMaterial3 = true,
|
||||
}) {
|
||||
return ThemeData(
|
||||
brightness: brightness,
|
||||
useMaterial3: useMaterial3,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
brightness: brightness,
|
||||
seedColor: data.seedColor,
|
||||
),
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
scaffoldBackgroundColor: Colors.transparent,
|
||||
fontFamily: data.fontFamily ?? 'Comfortaa',
|
||||
fontFamilyFallback: data.fontFamilyFallback ??
|
||||
[
|
||||
'NotoSansSC',
|
||||
'NotoSansHK',
|
||||
'NotoSansJP',
|
||||
if (PlatformInfo.isWeb) 'NotoSansEmoji',
|
||||
],
|
||||
typography: Typography.material2021(
|
||||
colorScheme: brightness == Brightness.light
|
||||
? const ColorScheme.light()
|
||||
: const ColorScheme.dark(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ class AccountAvatar extends StatelessWidget {
|
||||
final Color? bgColor;
|
||||
final Color? feColor;
|
||||
final double? radius;
|
||||
final Widget? fallbackWidget;
|
||||
|
||||
const AccountAvatar({
|
||||
super.key,
|
||||
@ -14,6 +15,7 @@ class AccountAvatar extends StatelessWidget {
|
||||
this.bgColor,
|
||||
this.feColor,
|
||||
this.radius,
|
||||
this.fallbackWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
@ -35,11 +37,12 @@ class AccountAvatar extends StatelessWidget {
|
||||
backgroundColor: bgColor,
|
||||
backgroundImage: !isEmpty ? AutoCacheImage.provider(url) : null,
|
||||
child: isEmpty
|
||||
? Icon(
|
||||
Icons.account_circle,
|
||||
size: radius != null ? radius! * 1.2 : 24,
|
||||
color: feColor,
|
||||
)
|
||||
? (fallbackWidget ??
|
||||
Icon(
|
||||
Icons.account_circle,
|
||||
size: radius != null ? radius! * 1.2 : 24,
|
||||
color: feColor,
|
||||
))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ class AccountHeadingWidget extends StatelessWidget {
|
||||
final AccountProfile? profile;
|
||||
final List<AccountBadge>? badges;
|
||||
final List<Widget>? extraWidgets;
|
||||
final List<Widget>? appendWidgets;
|
||||
|
||||
final Future<Response>? status;
|
||||
final Function? onEditStatus;
|
||||
@ -39,6 +40,7 @@ class AccountHeadingWidget extends StatelessWidget {
|
||||
this.profile,
|
||||
this.status,
|
||||
this.extraWidgets,
|
||||
this.appendWidgets,
|
||||
this.onEditStatus,
|
||||
});
|
||||
|
||||
@ -257,6 +259,7 @@ class AccountHeadingWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
).paddingSymmetric(horizontal: 16),
|
||||
...?appendWidgets?.map((x) => x.paddingSymmetric(horizontal: 16)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -106,10 +106,14 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
|
||||
extraWidgets: [
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(
|
||||
Icons.contact_page_outlined,
|
||||
),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
title: Text('visitProfilePage'.tr),
|
||||
subtitle: Text('learnMoreAboutPerson'.tr),
|
||||
visualDensity:
|
||||
const VisualDensity(horizontal: -4, vertical: -2),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
|
@ -28,42 +28,46 @@ class SilverRelativeList extends StatelessWidget {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme
|
||||
.of(context)
|
||||
.colorScheme
|
||||
.surface,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
AccountProfilePopup(
|
||||
name: element.related.name,
|
||||
),
|
||||
builder: (context) => AccountProfilePopup(
|
||||
name: element.related.name,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if(element.status != 1 && element.status != 3)
|
||||
if (element.status != 1 && element.status != 3)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check),
|
||||
onPressed: () {
|
||||
final RelationshipProvider provider = Get.find();
|
||||
if (element.status == 0) {
|
||||
provider.handleRelation(element, true).then((_) => onUpdate());
|
||||
provider
|
||||
.handleRelation(element, true)
|
||||
.then((_) => onUpdate());
|
||||
} else {
|
||||
provider.editRelation(element, 1).then((_) => onUpdate());
|
||||
provider
|
||||
.editRelation(element.relatedId, 1)
|
||||
.then((_) => onUpdate());
|
||||
}
|
||||
},
|
||||
),
|
||||
if(element.status != 2 && element.status != 3)
|
||||
if (element.status != 2 && element.status != 3)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
final RelationshipProvider provider = Get.find();
|
||||
if (element.status == 0) {
|
||||
provider.handleRelation(element, false).then((_) => onUpdate());
|
||||
provider
|
||||
.handleRelation(element, false)
|
||||
.then((_) => onUpdate());
|
||||
} else {
|
||||
provider.editRelation(element, 2).then((_) => onUpdate());
|
||||
provider
|
||||
.editRelation(element.relatedId, 2)
|
||||
.then((_) => onUpdate());
|
||||
}
|
||||
},
|
||||
),
|
||||
|
@ -396,7 +396,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
|
||||
),
|
||||
if (!element.isCompleted &&
|
||||
element.error == null &&
|
||||
canBeCrop)
|
||||
canBeCrop &&
|
||||
PlatformInfo.canCropImage)
|
||||
Obx(
|
||||
() => IconButton(
|
||||
color: Colors.teal,
|
||||
@ -744,8 +745,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
|
||||
return IgnorePointer(
|
||||
ignoring: _uploadController.isUploading.value,
|
||||
child: Container(
|
||||
height: 64,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
@ -754,67 +755,72 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 0,
|
||||
alignment: WrapAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
children: [
|
||||
if ((PlatformInfo.isDesktop ||
|
||||
PlatformInfo.isIOS ||
|
||||
PlatformInfo.isWeb) &&
|
||||
!widget.imageOnly)
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.paste),
|
||||
label: Text('attachmentAddClipboard'.tr),
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
onPressed: () => _pasteFileToUpload(),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add_photo_alternate),
|
||||
label: Text('attachmentAddGalleryPhoto'.tr),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
children: [
|
||||
if ((PlatformInfo.isDesktop ||
|
||||
PlatformInfo.isIOS ||
|
||||
PlatformInfo.isWeb) &&
|
||||
!widget.imageOnly)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.paste),
|
||||
tooltip: 'attachmentAddClipboard'.tr,
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
onPressed: () => _pickPhotoToUpload(),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: () => _pasteFileToUpload(),
|
||||
),
|
||||
if (!widget.imageOnly)
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.add_road),
|
||||
label: Text('attachmentAddGalleryVideo'.tr),
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
onPressed: () => _pickVideoToUpload(),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.photo_camera_back),
|
||||
label: Text('attachmentAddCameraPhoto'.tr),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_photo_alternate),
|
||||
tooltip: 'attachmentAddGalleryPhoto'.tr,
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: () => _pickPhotoToUpload(),
|
||||
),
|
||||
if (!widget.imageOnly)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_road),
|
||||
tooltip: 'attachmentAddGalleryVideo'.tr,
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: () => _pickVideoToUpload(),
|
||||
),
|
||||
if (PlatformInfo.isMobile)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.photo_camera_back),
|
||||
tooltip: 'attachmentAddCameraPhoto'.tr,
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: () => _takeMediaToUpload(false),
|
||||
),
|
||||
if (!widget.imageOnly)
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.video_camera_back_outlined),
|
||||
label: Text('attachmentAddCameraVideo'.tr),
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
onPressed: () => _takeMediaToUpload(true),
|
||||
),
|
||||
if (!widget.imageOnly)
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.file_present_rounded),
|
||||
label: Text('attachmentAddFile'.tr),
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
onPressed: () => _pickFileToUpload(),
|
||||
),
|
||||
if (!widget.imageOnly)
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.link),
|
||||
label: Text('attachmentAddFile'.tr),
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
onPressed: () => _linkAttachments(),
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 12),
|
||||
),
|
||||
if (!widget.imageOnly && PlatformInfo.isMobile)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.video_camera_back_outlined),
|
||||
tooltip: 'attachmentAddCameraVideo'.tr,
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: () => _takeMediaToUpload(true),
|
||||
),
|
||||
if (!widget.imageOnly)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.file_present_rounded),
|
||||
tooltip: 'attachmentAddFile'.tr,
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: () => _pickFileToUpload(),
|
||||
),
|
||||
if (!widget.imageOnly)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.link),
|
||||
tooltip: 'attachmentAddLink'.tr,
|
||||
style: const ButtonStyle(visualDensity: density),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: () => _linkAttachments(),
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 12),
|
||||
)
|
||||
.animate(
|
||||
target: _uploadController.isUploading.value ? 0 : 1,
|
||||
|
@ -21,6 +21,7 @@ class AttachmentItem extends StatefulWidget {
|
||||
final bool showBadge;
|
||||
final bool showHideButton;
|
||||
final bool autoload;
|
||||
final bool isDense;
|
||||
final BoxFit fit;
|
||||
final String? badge;
|
||||
final Function? onHide;
|
||||
@ -34,6 +35,7 @@ class AttachmentItem extends StatefulWidget {
|
||||
this.showBadge = true,
|
||||
this.showHideButton = true,
|
||||
this.autoload = false,
|
||||
this.isDense = false,
|
||||
this.onHide,
|
||||
});
|
||||
|
||||
@ -53,6 +55,7 @@ class _AttachmentItemState extends State<AttachmentItem> {
|
||||
fit: widget.fit,
|
||||
showBadge: widget.showBadge,
|
||||
showHideButton: widget.showHideButton,
|
||||
isDense: widget.isDense,
|
||||
onHide: widget.onHide,
|
||||
);
|
||||
case 'video':
|
||||
@ -120,6 +123,7 @@ class _AttachmentItemImage extends StatelessWidget {
|
||||
final bool showBadge;
|
||||
final bool showHideButton;
|
||||
final BoxFit fit;
|
||||
final bool isDense;
|
||||
final String? badge;
|
||||
final Function? onHide;
|
||||
|
||||
@ -128,6 +132,7 @@ class _AttachmentItemImage extends StatelessWidget {
|
||||
required this.item,
|
||||
required this.showBadge,
|
||||
required this.showHideButton,
|
||||
required this.isDense,
|
||||
required this.fit,
|
||||
this.badge,
|
||||
this.onHide,
|
||||
@ -146,6 +151,7 @@ class _AttachmentItemImage extends StatelessWidget {
|
||||
'/attachments/${item.rid}',
|
||||
),
|
||||
fit: fit,
|
||||
isDense: isDense,
|
||||
),
|
||||
if (showBadge && badge != null)
|
||||
Positioned(
|
||||
|
@ -177,9 +177,6 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
if (element == null) return const SizedBox.shrink();
|
||||
double ratio = element.metadata?['ratio']?.toDouble() ?? 16 / 9;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: widget.columnMaxWidth,
|
||||
maxHeight: 640,
|
||||
@ -247,7 +244,7 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
maxHeight: widget.flatMaxHeight,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
color: Colors.transparent,
|
||||
border: Border.symmetric(
|
||||
horizontal: BorderSide(
|
||||
width: 0.3,
|
||||
@ -257,6 +254,7 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
),
|
||||
child: CarouselSlider.builder(
|
||||
options: CarouselOptions(
|
||||
animateToClosest: true,
|
||||
aspectRatio: _aspectRatio,
|
||||
viewportFraction:
|
||||
widget.viewport ?? (widget.attachmentsId.length > 1 ? 0.95 : 1),
|
||||
@ -319,6 +317,7 @@ class AttachmentListEntry extends StatelessWidget {
|
||||
width: width ?? MediaQuery.of(context).size.width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: showBorder
|
||||
? Border.symmetric(
|
||||
vertical: BorderSide(
|
||||
@ -338,6 +337,7 @@ class AttachmentListEntry extends StatelessWidget {
|
||||
badge: showBadge ? badgeContent : null,
|
||||
showHideButton: !item!.isMature || showMature,
|
||||
autoload: autoload,
|
||||
isDense: isDense,
|
||||
onHide: () {
|
||||
onReveal(false);
|
||||
},
|
||||
|
@ -34,8 +34,17 @@ class AutoCacheImage extends StatelessWidget {
|
||||
progressIndicatorBuilder: noProgressIndicator
|
||||
? null
|
||||
: (context, url, downloadProgress) => Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: downloadProgress.progress,
|
||||
child: TweenAnimationBuilder(
|
||||
tween: Tween(
|
||||
begin: 0,
|
||||
end: downloadProgress.progress ?? 0,
|
||||
),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
builder: (context, value, _) => CircularProgressIndicator(
|
||||
value: downloadProgress.progress != null
|
||||
? value.toDouble()
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: noErrorWidget
|
||||
@ -74,11 +83,20 @@ class AutoCacheImage extends StatelessWidget {
|
||||
ImageChunkEvent? loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
child: TweenAnimationBuilder(
|
||||
tween: Tween(
|
||||
begin: 0,
|
||||
end: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: 0,
|
||||
),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
builder: (context, value, _) => CircularProgressIndicator(
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? value.toDouble()
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@ -98,12 +98,12 @@ class ChannelCallIndicator extends StatelessWidget {
|
||||
child: Text('callJoin'.tr),
|
||||
);
|
||||
} else if (call.channel.value?.id == channel.id &&
|
||||
!AppTheme.isLargeScreen(context)) {
|
||||
!AppTheme.isUltraLargeScreen(context)) {
|
||||
return TextButton(
|
||||
onPressed: () => onJoin(),
|
||||
child: Text('callResume'.tr),
|
||||
);
|
||||
} else if (!AppTheme.isLargeScreen(context)) {
|
||||
} else if (!AppTheme.isUltraLargeScreen(context)) {
|
||||
return TextButton(
|
||||
onPressed: null,
|
||||
child: Text('callJoin'.tr),
|
||||
|
@ -4,18 +4,18 @@ import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:solian/controllers/chat_events_controller.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/database/database.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
|
||||
class ChannelListWidget extends StatefulWidget {
|
||||
final List<Channel> channels;
|
||||
final int selfId;
|
||||
final bool isDense;
|
||||
final bool isCollapsed;
|
||||
final bool noCategory;
|
||||
final bool useReplace;
|
||||
final Function(Channel)? onSelected;
|
||||
|
||||
@ -23,9 +23,6 @@ class ChannelListWidget extends StatefulWidget {
|
||||
super.key,
|
||||
required this.channels,
|
||||
required this.selfId,
|
||||
this.isDense = false,
|
||||
this.isCollapsed = false,
|
||||
this.noCategory = false,
|
||||
this.useReplace = false,
|
||||
this.onSelected,
|
||||
});
|
||||
@ -35,43 +32,25 @@ class ChannelListWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChannelListWidgetState extends State<ChannelListWidget> {
|
||||
final List<Channel> _globalChannels = List.empty(growable: true);
|
||||
final Map<String, List<Channel>> _inRealms = {};
|
||||
Map<int, LocalMessageEventTableData>? _lastMessages;
|
||||
|
||||
final ChatEventController _eventController = ChatEventController();
|
||||
|
||||
void _mapChannels() {
|
||||
_inRealms.clear();
|
||||
_globalChannels.clear();
|
||||
|
||||
if (widget.noCategory) {
|
||||
_globalChannels.addAll(widget.channels);
|
||||
return;
|
||||
}
|
||||
|
||||
for (final channel in widget.channels) {
|
||||
if (channel.realmId != null) {
|
||||
if (_inRealms[channel.realm!.alias] == null) {
|
||||
_inRealms[channel.realm!.alias] = List.empty(growable: true);
|
||||
}
|
||||
_inRealms[channel.realm!.alias]!.add(channel);
|
||||
} else {
|
||||
_globalChannels.add(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ChannelListWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
setState(() => _mapChannels());
|
||||
Future<void> _loadLastMessages() async {
|
||||
final messages = await _eventController.src.getLastInAllChannels();
|
||||
setState(() {
|
||||
_lastMessages = messages
|
||||
.map((k, v) => MapEntry(k, v.firstOrNull))
|
||||
.cast<int, LocalMessageEventTableData>();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapChannels();
|
||||
_eventController.initialize();
|
||||
_eventController.initialize().then((_) {
|
||||
_loadLastMessages();
|
||||
});
|
||||
}
|
||||
|
||||
void _gotoChannel(Channel item) {
|
||||
@ -98,107 +77,183 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDirectMessageDescription(Channel item, ChannelMember otherside) {
|
||||
Widget _buildTitle(Channel item, ChannelMember? otherside) {
|
||||
if (otherside != null) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: Text(otherside.account.nick)),
|
||||
if (_lastMessages != null && _lastMessages![item.id] != null)
|
||||
Text(
|
||||
DateFormat('MM/dd').format(
|
||||
_lastMessages![item.id]!.createdAt.toLocal(),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: Text(item.name)),
|
||||
if (_lastMessages != null && _lastMessages![item.id] != null)
|
||||
Text(
|
||||
DateFormat('MM/dd').format(
|
||||
_lastMessages![item.id]!.createdAt.toLocal(),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubtitle(Channel item, ChannelMember? otherside) {
|
||||
if (PlatformInfo.isWeb) {
|
||||
return Text('channelDirectDescription'.trParams(
|
||||
{'username': '@${otherside.account.name}'},
|
||||
));
|
||||
return otherside != null
|
||||
? Text(
|
||||
'channelDirectDescription'.trParams(
|
||||
{'username': '@${otherside.account.name}'},
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text(
|
||||
item.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
return FutureBuilder(
|
||||
future: Future.delayed(
|
||||
const Duration(milliseconds: 500),
|
||||
() => _eventController.src.getLastInChannel(item),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData && snapshot.data == null) {
|
||||
return Text('channelDirectDescription'.trParams(
|
||||
{'username': '@${otherside.account.name}'},
|
||||
));
|
||||
}
|
||||
|
||||
final data = snapshot.data!.data!;
|
||||
return Text(
|
||||
'${data.sender.account.nick}: ${data.body['text'] ?? 'Unsupported message to preview'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
return AnimatedSwitcher(
|
||||
switchInCurve: Curves.easeIn,
|
||||
switchOutCurve: Curves.easeOut,
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: (_lastMessages == null || _lastMessages![item.id] == null)
|
||||
? Builder(
|
||||
builder: (context) {
|
||||
return otherside != null
|
||||
? Text(
|
||||
'channelDirectDescription'.trParams(
|
||||
{'username': '@${otherside.account.name}'},
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text(
|
||||
item.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Builder(
|
||||
builder: (context) {
|
||||
final data = _lastMessages![item.id]!.data!;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (item.type == 0)
|
||||
Badge(
|
||||
label: Text(data.sender.account.nick),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
textColor:
|
||||
Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
if (item.type == 0) const Gap(6),
|
||||
if (data.body['text'] != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.body['text'],
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
else
|
||||
Badge(label: Text('unablePreview'.tr)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
layoutBuilder: (currentChild, previousChildren) {
|
||||
return Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: <Widget>[
|
||||
...previousChildren,
|
||||
if (currentChild != null) currentChild,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntry(Channel item) {
|
||||
final padding = widget.isDense
|
||||
? const EdgeInsets.symmetric(horizontal: 20)
|
||||
: const EdgeInsets.symmetric(horizontal: 16);
|
||||
const padding = EdgeInsets.symmetric(horizontal: 20);
|
||||
|
||||
if (item.type == 1) {
|
||||
final otherside =
|
||||
item.members!.where((e) => e.account.id != widget.selfId).first;
|
||||
final otherside =
|
||||
item.members!.where((e) => e.account.id != widget.selfId).firstOrNull;
|
||||
|
||||
if (item.type == 1 && otherside != null) {
|
||||
final avatar = AccountAvatar(
|
||||
content: otherside.account.avatar,
|
||||
radius: widget.isDense ? 12 : 20,
|
||||
radius: 20,
|
||||
bgColor: Theme.of(context).colorScheme.primary,
|
||||
feColor: Theme.of(context).colorScheme.onPrimary,
|
||||
);
|
||||
|
||||
if (widget.isCollapsed) {
|
||||
return Tooltip(
|
||||
message: otherside.account.nick,
|
||||
child: InkWell(
|
||||
child: avatar.paddingSymmetric(vertical: 12),
|
||||
onTap: () => _gotoChannel(item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: avatar,
|
||||
contentPadding: padding,
|
||||
title: Text(otherside.account.nick),
|
||||
subtitle: !widget.isDense
|
||||
? _buildDirectMessageDescription(item, otherside)
|
||||
: null,
|
||||
title: _buildTitle(item, otherside),
|
||||
subtitle: _buildSubtitle(item, otherside),
|
||||
onTap: () => _gotoChannel(item),
|
||||
);
|
||||
} else {
|
||||
final avatar = CircleAvatar(
|
||||
backgroundColor: item.realmId == null
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
radius: widget.isDense ? 12 : 20,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
radius: 20,
|
||||
child: FaIcon(
|
||||
FontAwesomeIcons.hashtag,
|
||||
color: item.realmId == null
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
size: widget.isDense ? 12 : 16,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
size: 16,
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.isCollapsed) {
|
||||
return Tooltip(
|
||||
message: item.name,
|
||||
child: InkWell(
|
||||
child: avatar.paddingSymmetric(vertical: 12),
|
||||
onTap: () => _gotoChannel(item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
minTileHeight: widget.isDense ? 48 : null,
|
||||
leading: avatar,
|
||||
minTileHeight: null,
|
||||
leading: item.realmId == null
|
||||
? avatar
|
||||
: badges.Badge(
|
||||
position: badges.BadgePosition.bottomEnd(bottom: -4, end: -6),
|
||||
badgeStyle: badges.BadgeStyle(
|
||||
badgeColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
padding: const EdgeInsets.all(2),
|
||||
elevation: 8,
|
||||
),
|
||||
badgeContent: AccountAvatar(
|
||||
content: item.realm?.avatar,
|
||||
radius: 10,
|
||||
fallbackWidget: const Icon(
|
||||
Icons.workspaces,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
child: avatar,
|
||||
),
|
||||
contentPadding: padding,
|
||||
title: Text(item.name),
|
||||
subtitle: !widget.isDense
|
||||
? Text(
|
||||
item.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
title: _buildTitle(item, null),
|
||||
subtitle: _buildSubtitle(item, null),
|
||||
onTap: () => _gotoChannel(item),
|
||||
);
|
||||
}
|
||||
@ -206,49 +261,16 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.noCategory) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverList.builder(
|
||||
itemCount: _globalChannels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final element = _globalChannels[index];
|
||||
return _buildEntry(element);
|
||||
},
|
||||
),
|
||||
SliverGap(max(16, MediaQuery.of(context).padding.bottom)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverList.builder(
|
||||
itemCount: _globalChannels.length,
|
||||
itemCount: widget.channels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final element = _globalChannels[index];
|
||||
final element = widget.channels[index];
|
||||
return _buildEntry(element);
|
||||
},
|
||||
),
|
||||
SliverList.list(
|
||||
children: _inRealms.entries.map((element) {
|
||||
return ExpansionTile(
|
||||
tilePadding: const EdgeInsets.only(left: 20, right: 24),
|
||||
minTileHeight: 48,
|
||||
title: Text(element.value.first.realm!.name),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.teal,
|
||||
radius: widget.isDense ? 12 : 24,
|
||||
child: Icon(
|
||||
Icons.workspaces,
|
||||
color: Colors.white,
|
||||
size: widget.isDense ? 12 : 16,
|
||||
),
|
||||
),
|
||||
children: element.value.map((x) => _buildEntry(x)).toList(),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
SliverGap(max(16, MediaQuery.of(context).padding.bottom)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/controllers/chat_events_controller.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
@ -9,6 +10,7 @@ import 'package:solian/widgets/chat/chat_event_action.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
class ChatEventList extends StatelessWidget {
|
||||
final bool noAnimated;
|
||||
final String scope;
|
||||
final Channel channel;
|
||||
final ChatEventController chatController;
|
||||
@ -23,6 +25,7 @@ class ChatEventList extends StatelessWidget {
|
||||
required this.chatController,
|
||||
required this.onEdit,
|
||||
required this.onReply,
|
||||
this.noAnimated = false,
|
||||
});
|
||||
|
||||
bool _checkMessageMergeable(Event? a, Event? b) {
|
||||
@ -63,15 +66,32 @@ class ChatEventList extends StatelessWidget {
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: ChatEvent(
|
||||
key: Key('m${item!.uuid}'),
|
||||
item: item,
|
||||
isMerged: isMerged,
|
||||
chatController: chatController,
|
||||
).paddingOnly(
|
||||
top: !isMerged ? 8 : 0,
|
||||
bottom: !hasMerged ? 8 : 0,
|
||||
),
|
||||
child: Builder(builder: (context) {
|
||||
final widget = ChatEvent(
|
||||
key: Key('m${item!.uuid}'),
|
||||
item: item,
|
||||
isMerged: isMerged,
|
||||
chatController: chatController,
|
||||
).paddingOnly(
|
||||
top: !isMerged ? 8 : 0,
|
||||
bottom: !hasMerged ? 8 : 0,
|
||||
);
|
||||
|
||||
if (noAnimated) {
|
||||
return widget;
|
||||
} else {
|
||||
return widget
|
||||
.animate(
|
||||
key: Key('animated-m${item.uuid}'),
|
||||
)
|
||||
.slideY(
|
||||
curve: Curves.fastLinearToSlowEaseIn,
|
||||
duration: 250.ms,
|
||||
begin: 0.5,
|
||||
end: 0,
|
||||
);
|
||||
}
|
||||
}),
|
||||
onLongPress: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
@ -79,7 +99,7 @@ class ChatEventList extends StatelessWidget {
|
||||
builder: (context) => ChatEventAction(
|
||||
channel: channel,
|
||||
realm: channel.realm,
|
||||
item: item,
|
||||
item: item!,
|
||||
onEdit: () {
|
||||
onEdit(item);
|
||||
},
|
||||
|
@ -49,6 +49,7 @@ class ChatEventMessage extends StatelessWidget {
|
||||
return MarkdownTextContent(
|
||||
parentId: 'm${item.id}',
|
||||
isSelectable: true,
|
||||
isAutoWarp: true,
|
||||
content: body.text,
|
||||
);
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ class DailySignHistoryChartDialog extends StatelessWidget {
|
||||
|
||||
const DailySignHistoryChartDialog({super.key, required this.data});
|
||||
|
||||
static List<String> signSymbols = ['大凶', '凶', '中平', '吉', '大吉'];
|
||||
static final List<String> signSymbols = ['大凶', '凶', '中平', '吉', '大吉'];
|
||||
|
||||
DateTime? get _firstRecordDate => data?.map((x) => x.createdAt).reduce(
|
||||
(a, b) => DateTime.fromMillisecondsSinceEpoch(
|
||||
@ -42,215 +42,222 @@ class DailySignHistoryChartDialog extends StatelessWidget {
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'dailySignHistoryRecent'.tr,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
).paddingOnly(bottom: 18),
|
||||
SizedBox(
|
||||
height: 180,
|
||||
width: max(640, MediaQuery.of(context).size.width),
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
isCurved: true,
|
||||
isStrokeCapRound: true,
|
||||
isStrokeJoinRound: true,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: List.filled(
|
||||
data!.length,
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.3),
|
||||
).toList(),
|
||||
),
|
||||
),
|
||||
spots: data!
|
||||
.map(
|
||||
(x) => FlSpot(
|
||||
x.createdAt
|
||||
.copyWith(
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
)
|
||||
.millisecondsSinceEpoch
|
||||
.toDouble(),
|
||||
x.resultTier.toDouble(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => spots
|
||||
.map((spot) => LineTooltipItem(
|
||||
'${signSymbols[spot.y.toInt()]}\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
|
||||
TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
getTooltipColor: (_) =>
|
||||
Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
)),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: 1,
|
||||
getTitlesWidget: (value, _) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
signSymbols[value.toInt()],
|
||||
textAlign: TextAlign.right,
|
||||
).paddingOnly(right: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
interval: 86400000,
|
||||
getTitlesWidget: (value, _) => Text(
|
||||
DateFormat('dd').format(
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
value.toInt(),
|
||||
),
|
||||
: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
Text(
|
||||
'dailySignHistoryRecent'.tr,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
).paddingOnly(bottom: 18),
|
||||
SizedBox(
|
||||
height: 180,
|
||||
width: max(640, MediaQuery.of(context).size.width),
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
isCurved: true,
|
||||
isStrokeCapRound: true,
|
||||
isStrokeJoinRound: true,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: List.filled(
|
||||
data!.length,
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.3),
|
||||
).toList(),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
).paddingOnly(top: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
gridData: const FlGridData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
),
|
||||
),
|
||||
).marginOnly(right: 24, bottom: 8, top: 8),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'dailySignHistoryReward'.tr,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
).paddingOnly(bottom: 18),
|
||||
SizedBox(
|
||||
height: 180,
|
||||
width: max(640, MediaQuery.of(context).size.width),
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
isCurved: true,
|
||||
isStrokeCapRound: true,
|
||||
isStrokeJoinRound: true,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: List.filled(
|
||||
data!.length,
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.3),
|
||||
).toList(),
|
||||
),
|
||||
),
|
||||
spots: data!
|
||||
.map(
|
||||
(x) => FlSpot(
|
||||
x.createdAt
|
||||
.copyWith(
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
)
|
||||
.millisecondsSinceEpoch
|
||||
.toDouble(),
|
||||
x.resultExperience.toDouble(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => spots
|
||||
.map((spot) => LineTooltipItem(
|
||||
'+${spot.y.toStringAsFixed(0)} EXP\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
|
||||
TextStyle(
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurface,
|
||||
spots: data!
|
||||
.map(
|
||||
(x) => FlSpot(
|
||||
x.createdAt
|
||||
.copyWith(
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
)
|
||||
.millisecondsSinceEpoch
|
||||
.toDouble(),
|
||||
x.resultTier.toDouble(),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
getTooltipColor: (_) =>
|
||||
Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
)),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => spots
|
||||
.map((spot) => LineTooltipItem(
|
||||
'${signSymbols[spot.y.toInt()]}\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
|
||||
TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
getTooltipColor: (_) => Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
getTitlesWidget: (value, _) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
value.toStringAsFixed(0),
|
||||
textAlign: TextAlign.right,
|
||||
).paddingOnly(right: 8),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: 1,
|
||||
getTitlesWidget: (value, _) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
signSymbols[value.toInt()],
|
||||
textAlign: TextAlign.right,
|
||||
).paddingOnly(right: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
interval: 86400000,
|
||||
getTitlesWidget: (value, _) => Text(
|
||||
DateFormat('dd').format(
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
value.toInt(),
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
).paddingOnly(top: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
interval: 86400000,
|
||||
getTitlesWidget: (value, _) => Text(
|
||||
DateFormat('dd').format(
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
value.toInt(),
|
||||
),
|
||||
gridData: const FlGridData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
),
|
||||
),
|
||||
).marginOnly(right: 24, bottom: 8, top: 8),
|
||||
const Gap(16),
|
||||
Text(
|
||||
'dailySignHistoryReward'.tr,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
).paddingOnly(bottom: 18),
|
||||
SizedBox(
|
||||
height: 180,
|
||||
width: max(640, MediaQuery.of(context).size.width),
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
isCurved: true,
|
||||
isStrokeCapRound: true,
|
||||
isStrokeJoinRound: true,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: List.filled(
|
||||
data!.length,
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withOpacity(0.3),
|
||||
).toList(),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
).paddingOnly(top: 8),
|
||||
),
|
||||
spots: data!
|
||||
.map(
|
||||
(x) => FlSpot(
|
||||
x.createdAt
|
||||
.copyWith(
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
)
|
||||
.millisecondsSinceEpoch
|
||||
.toDouble(),
|
||||
x.resultExperience.toDouble(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => spots
|
||||
.map((spot) => LineTooltipItem(
|
||||
'+${spot.y.toStringAsFixed(0)} EXP\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
|
||||
TextStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface,
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
getTooltipColor: (_) => Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
)),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
getTitlesWidget: (value, _) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
value.toStringAsFixed(0),
|
||||
textAlign: TextAlign.right,
|
||||
).paddingOnly(right: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
interval: 86400000,
|
||||
getTitlesWidget: (value, _) => Text(
|
||||
DateFormat('dd').format(
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
value.toInt(),
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
).paddingOnly(top: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
gridData: const FlGridData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
),
|
||||
gridData: const FlGridData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
),
|
||||
),
|
||||
).marginOnly(right: 24, bottom: 8, top: 8),
|
||||
],
|
||||
).marginOnly(right: 24, bottom: 8, top: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -74,9 +74,13 @@ class LinkExpansion extends StatelessWidget {
|
||||
),
|
||||
).paddingOnly(right: 8),
|
||||
if (snapshot.data!.siteName != null)
|
||||
Text(
|
||||
snapshot.data!.siteName!,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
Expanded(
|
||||
child: Text(
|
||||
snapshot.data!.siteName!,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
).paddingOnly(
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown_selectionarea/flutter_markdown.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:markdown/markdown.dart' as markdown;
|
||||
import 'package:markdown/markdown.dart';
|
||||
@ -15,6 +16,7 @@ class MarkdownTextContent extends StatelessWidget {
|
||||
final String parentId;
|
||||
final bool isSelectable;
|
||||
final bool isLargeText;
|
||||
final bool isAutoWarp;
|
||||
|
||||
const MarkdownTextContent({
|
||||
super.key,
|
||||
@ -22,139 +24,175 @@ class MarkdownTextContent extends StatelessWidget {
|
||||
required this.parentId,
|
||||
this.isSelectable = false,
|
||||
this.isLargeText = false,
|
||||
this.isAutoWarp = false,
|
||||
});
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
final emojiRegex = RegExp(r':([-\w]+):');
|
||||
final emojiMatch = emojiRegex.allMatches(content);
|
||||
final isOnlyEmoji = content.replaceAll(emojiRegex, '').trim().isEmpty;
|
||||
final stickerRegex = RegExp(r':([-\w]+):');
|
||||
|
||||
return Markdown(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: content,
|
||||
padding: EdgeInsets.zero,
|
||||
styleSheet: MarkdownStyleSheet.fromTheme(
|
||||
Theme.of(context),
|
||||
).copyWith(
|
||||
textScaleFactor: isLargeText ? 1.1 : 1,
|
||||
blockquote: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
horizontalRuleDecoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
width: 1.0,
|
||||
color: Theme.of(context).dividerColor,
|
||||
// Split the content into paragraphs
|
||||
final paragraphs = content.split(RegExp(r'\n\s*\n'));
|
||||
|
||||
// Iterate over each paragraph to process stickers individually
|
||||
List<Widget> contentWidgets = [];
|
||||
for (var idx = 0; idx < paragraphs.length; idx++) {
|
||||
// Getting paragraph
|
||||
var paragraph = paragraphs[idx];
|
||||
|
||||
// Auto adding new-lines
|
||||
if (isAutoWarp) {
|
||||
paragraph = paragraph.replaceAll('\n', '\\\n');
|
||||
}
|
||||
|
||||
// Matching stickers
|
||||
final stickerMatch = stickerRegex.allMatches(paragraph);
|
||||
final isOnlySticker =
|
||||
paragraph.replaceAll(stickerRegex, '').trim().isEmpty;
|
||||
|
||||
contentWidgets.add(
|
||||
Markdown(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: paragraph,
|
||||
padding: EdgeInsets.zero,
|
||||
styleSheet: MarkdownStyleSheet.fromTheme(
|
||||
Theme.of(context),
|
||||
).copyWith(
|
||||
textScaleFactor: isLargeText ? 1.1 : 1,
|
||||
blockquote: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
horizontalRuleDecoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
width: 1.0,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
extensionSet: markdown.ExtensionSet(
|
||||
markdown.ExtensionSet.gitHubFlavored.blockSyntaxes,
|
||||
<markdown.InlineSyntax>[
|
||||
_UserNameCardInlineSyntax(),
|
||||
_CustomEmoteInlineSyntax(),
|
||||
markdown.EmojiSyntax(),
|
||||
markdown.AutolinkSyntax(),
|
||||
markdown.AutolinkExtensionSyntax(),
|
||||
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes
|
||||
],
|
||||
),
|
||||
onTapLink: (text, href, title) async {
|
||||
if (href == null) return;
|
||||
if (href.startsWith('solink://')) {
|
||||
final segments = href.replaceFirst('solink://', '').split('/');
|
||||
switch (segments[0]) {
|
||||
case 'users':
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
context: context,
|
||||
builder: (context) => AccountProfilePopup(
|
||||
name: segments[1],
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await launchUrlString(
|
||||
href,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
imageBuilder: (uri, title, alt) {
|
||||
var url = uri.toString();
|
||||
double? width, height;
|
||||
BoxFit? fit;
|
||||
if (url.startsWith('solink://')) {
|
||||
final segments = url.replaceFirst('solink://', '').split('/');
|
||||
switch (segments[0]) {
|
||||
case 'stickers':
|
||||
double radius = 8;
|
||||
final StickerProvider sticker = Get.find();
|
||||
if (emojiMatch.length <= 1 && isOnlyEmoji) {
|
||||
width = 128;
|
||||
height = 128;
|
||||
} else if (emojiMatch.length <= 3 && isOnlyEmoji) {
|
||||
width = 32;
|
||||
height = 32;
|
||||
} else {
|
||||
radius = 4;
|
||||
width = 16;
|
||||
height = 16;
|
||||
extensionSet: markdown.ExtensionSet(
|
||||
markdown.ExtensionSet.gitHubFlavored.blockSyntaxes,
|
||||
<markdown.InlineSyntax>[
|
||||
_UserNameCardInlineSyntax(),
|
||||
_CustomEmoteInlineSyntax(),
|
||||
markdown.EmojiSyntax(),
|
||||
markdown.AutolinkSyntax(),
|
||||
markdown.AutolinkExtensionSyntax(),
|
||||
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes
|
||||
],
|
||||
),
|
||||
onTapLink: (text, href, title) async {
|
||||
if (href == null) return;
|
||||
if (href.startsWith('solink://')) {
|
||||
final segments = href.replaceFirst('solink://', '').split('/');
|
||||
switch (segments[0]) {
|
||||
case 'users':
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
context: context,
|
||||
builder: (context) => AccountProfilePopup(
|
||||
name: segments[1],
|
||||
),
|
||||
);
|
||||
}
|
||||
fit = BoxFit.contain;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.all(Radius.circular(radius)),
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: FutureBuilder(
|
||||
future: sticker.getStickerByAlias(segments[1]),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return AutoCacheImage(
|
||||
snapshot.data!.imageUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
noErrorWidget: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
).paddingSymmetric(vertical: 4);
|
||||
case 'attachments':
|
||||
const radius = BorderRadius.all(Radius.circular(8));
|
||||
return LimitedBox(
|
||||
maxHeight: MediaQuery.of(context).size.width,
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: AttachmentSelfContainedEntry(
|
||||
isDense: true,
|
||||
parentId: parentId,
|
||||
rid: segments[1],
|
||||
),
|
||||
),
|
||||
).paddingSymmetric(vertical: 4);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return AutoCacheImage(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
);
|
||||
},
|
||||
await launchUrlString(
|
||||
href,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
imageBuilder: (uri, title, alt) {
|
||||
var url = uri.toString();
|
||||
double? width, height;
|
||||
BoxFit? fit;
|
||||
if (url.startsWith('solink://')) {
|
||||
final segments = url.replaceFirst('solink://', '').split('/');
|
||||
switch (segments[0]) {
|
||||
case 'stickers':
|
||||
double radius = 8;
|
||||
final StickerProvider sticker = Get.find();
|
||||
|
||||
// Adjust sticker size based on the sticker count in this paragraph
|
||||
if (stickerMatch.length <= 1 && isOnlySticker) {
|
||||
width = 128;
|
||||
height = 128;
|
||||
} else if (stickerMatch.length <= 3 && isOnlySticker) {
|
||||
width = 32;
|
||||
height = 32;
|
||||
} else {
|
||||
radius = 4;
|
||||
width = 16;
|
||||
height = 16;
|
||||
}
|
||||
fit = BoxFit.contain;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.all(Radius.circular(radius)),
|
||||
child: Container(
|
||||
width: width,
|
||||
height: height,
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: FutureBuilder(
|
||||
future: sticker.getStickerByAlias(segments[1]),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator());
|
||||
}
|
||||
return AutoCacheImage(
|
||||
snapshot.data!.imageUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
noErrorWidget: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
).paddingSymmetric(vertical: 4);
|
||||
case 'attachments':
|
||||
const radius = BorderRadius.all(Radius.circular(8));
|
||||
return LimitedBox(
|
||||
maxHeight: MediaQuery.of(context).size.width,
|
||||
child: ClipRRect(
|
||||
borderRadius: radius,
|
||||
child: AttachmentSelfContainedEntry(
|
||||
isDense: true,
|
||||
parentId: parentId,
|
||||
rid: segments[1],
|
||||
),
|
||||
),
|
||||
).paddingSymmetric(vertical: 4);
|
||||
}
|
||||
}
|
||||
return AutoCacheImage(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (idx < paragraphs.length - 1) {
|
||||
contentWidgets.add(const Gap(4));
|
||||
}
|
||||
}
|
||||
|
||||
// Return the list of widgets for the paragraphs
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: contentWidgets,
|
||||
);
|
||||
}
|
||||
|
||||
|
80
lib/widgets/navigation/app_account_widget.dart
Normal file
80
lib/widgets/navigation/app_account_widget.dart
Normal file
@ -0,0 +1,80 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/models/account_status.dart';
|
||||
import 'package:solian/providers/account_status.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/relation.dart';
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
|
||||
class AppAccountWidget extends StatefulWidget {
|
||||
const AppAccountWidget({super.key});
|
||||
|
||||
@override
|
||||
State<AppAccountWidget> createState() => _AppAccountWidgetState();
|
||||
}
|
||||
|
||||
class _AppAccountWidgetState extends State<AppAccountWidget> {
|
||||
AccountStatus? _accountStatus;
|
||||
|
||||
Future<void> _getStatus() async {
|
||||
final StatusProvider provider = Get.find();
|
||||
|
||||
final resp = await provider.getCurrentStatus();
|
||||
final status = AccountStatus.fromJson(resp.body);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_accountStatus = status;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_getStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AuthProvider auth = Get.find();
|
||||
|
||||
return Obx(() {
|
||||
if (auth.isAuthorized.isFalse || auth.userProfile.value == null) {
|
||||
return const Icon(Icons.account_circle);
|
||||
}
|
||||
|
||||
final statusBadgeColor = _accountStatus != null
|
||||
? StatusProvider.determineStatus(_accountStatus!).$2
|
||||
: Colors.grey;
|
||||
|
||||
final RelationshipProvider relations = Get.find();
|
||||
final accountNotifications = relations.friendRequestCount.value;
|
||||
|
||||
return badges.Badge(
|
||||
badgeContent: Text(
|
||||
accountNotifications.toString(),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
showBadge: accountNotifications > 0,
|
||||
position: badges.BadgePosition.topEnd(
|
||||
top: -10,
|
||||
end: -6,
|
||||
),
|
||||
child: badges.Badge(
|
||||
showBadge: _accountStatus != null,
|
||||
badgeStyle: badges.BadgeStyle(badgeColor: statusBadgeColor),
|
||||
position: badges.BadgePosition.bottomEnd(
|
||||
bottom: 0,
|
||||
end: -2,
|
||||
),
|
||||
child: AccountAvatar(
|
||||
radius: 14,
|
||||
content: auth.userProfile.value!['avatar'],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@ -1,27 +1,33 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/utils.dart';
|
||||
import 'package:solian/widgets/navigation/app_account_widget.dart';
|
||||
|
||||
abstract class AppNavigation {
|
||||
static List<AppNavigationDestination> destinations = [
|
||||
AppNavigationDestination(
|
||||
icon: Icons.dashboard,
|
||||
label: 'dashboard'.tr,
|
||||
icon: const Icon(Icons.dashboard),
|
||||
label: 'dashboardNav'.tr,
|
||||
page: 'dashboard',
|
||||
),
|
||||
AppNavigationDestination(
|
||||
icon: Icons.newspaper,
|
||||
label: 'feed'.tr,
|
||||
page: 'feed',
|
||||
icon: const Icon(Icons.explore),
|
||||
label: 'explore'.tr,
|
||||
page: 'explore',
|
||||
),
|
||||
AppNavigationDestination(
|
||||
icon: Icons.workspaces,
|
||||
icon: const Icon(Icons.forum),
|
||||
label: 'chat'.tr,
|
||||
page: 'chat',
|
||||
),
|
||||
AppNavigationDestination(
|
||||
icon: const Icon(Icons.workspaces),
|
||||
label: 'realms'.tr,
|
||||
page: 'realms',
|
||||
),
|
||||
AppNavigationDestination(
|
||||
icon: Icons.forum,
|
||||
label: 'chat'.tr,
|
||||
page: 'chat',
|
||||
icon: const AppAccountWidget(),
|
||||
label: 'accountNav'.tr,
|
||||
page: 'account',
|
||||
),
|
||||
];
|
||||
|
||||
@ -30,7 +36,7 @@ abstract class AppNavigation {
|
||||
}
|
||||
|
||||
class AppNavigationDestination {
|
||||
final IconData icon;
|
||||
final Widget icon;
|
||||
final String label;
|
||||
final String page;
|
||||
|
||||
|
47
lib/widgets/navigation/app_navigation_bottom.dart
Normal file
47
lib/widgets/navigation/app_navigation_bottom.dart
Normal file
@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/widgets/navigation/app_navigation.dart';
|
||||
|
||||
class AppNavigationBottom extends StatefulWidget {
|
||||
final int initialIndex;
|
||||
|
||||
const AppNavigationBottom({super.key, this.initialIndex = 0});
|
||||
|
||||
@override
|
||||
State<AppNavigationBottom> createState() => _AppNavigationBottomState();
|
||||
}
|
||||
|
||||
class _AppNavigationBottomState extends State<AppNavigationBottom> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialIndex >= 0) {
|
||||
_currentIndex = widget.initialIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BottomNavigationBar(
|
||||
currentIndex: _currentIndex,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
showUnselectedLabels: false,
|
||||
showSelectedLabels: true,
|
||||
landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
|
||||
items: AppNavigation.destinations
|
||||
.map(
|
||||
(x) => BottomNavigationBarItem(
|
||||
icon: x.icon,
|
||||
label: x.label,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onTap: (idx) {
|
||||
setState(() => _currentIndex = idx);
|
||||
AppRouter.instance.goNamed(AppNavigation.destinations[idx].page);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -1,330 +0,0 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/models/account_status.dart';
|
||||
import 'package:solian/providers/account_status.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/relation.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/shells/root_shell.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/account/account_status_action.dart';
|
||||
import 'package:solian/widgets/navigation/app_navigation.dart';
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
import 'package:solian/widgets/navigation/app_navigation_region.dart';
|
||||
|
||||
class AppNavigationDrawer extends StatefulWidget {
|
||||
final String? routeName;
|
||||
|
||||
const AppNavigationDrawer({super.key, this.routeName});
|
||||
|
||||
@override
|
||||
State<AppNavigationDrawer> createState() => _AppNavigationDrawerState();
|
||||
}
|
||||
|
||||
class _AppNavigationDrawerState extends State<AppNavigationDrawer>
|
||||
with TickerProviderStateMixin {
|
||||
bool _isCollapsed = true;
|
||||
|
||||
late final AnimationController _drawerAnimationController =
|
||||
AnimationController(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
vsync: this,
|
||||
);
|
||||
late final Animation<double> _drawerAnimation = Tween<double>(
|
||||
begin: 80.0,
|
||||
end: 304.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _drawerAnimationController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
|
||||
AccountStatus? _accountStatus;
|
||||
|
||||
Future<void> _getStatus() async {
|
||||
final StatusProvider provider = Get.find();
|
||||
|
||||
final resp = await provider.getCurrentStatus();
|
||||
final status = AccountStatus.fromJson(resp.body);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_accountStatus = status;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Color get _unFocusColor =>
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
||||
|
||||
Widget _buildUserInfo() {
|
||||
return Obx(() {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (auth.isAuthorized.isFalse || auth.userProfile.value == null) {
|
||||
if (_isCollapsed) {
|
||||
return InkWell(
|
||||
child: const Icon(Icons.account_circle).paddingSymmetric(
|
||||
horizontal: 28,
|
||||
vertical: 20,
|
||||
),
|
||||
onTap: () {
|
||||
AppRouter.instance.goNamed('account');
|
||||
_closeDrawer();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
leading: const Icon(Icons.account_circle),
|
||||
title: !_isCollapsed ? Text('guest'.tr) : null,
|
||||
subtitle: !_isCollapsed ? Text('unsignedIn'.tr) : null,
|
||||
onTap: () {
|
||||
AppRouter.instance.goNamed('account');
|
||||
_closeDrawer();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final leading = Obx(() {
|
||||
final statusBadgeColor = _accountStatus != null
|
||||
? StatusProvider.determineStatus(_accountStatus!).$2
|
||||
: Colors.grey;
|
||||
|
||||
final RelationshipProvider relations = Get.find();
|
||||
final accountNotifications = relations.friendRequestCount.value;
|
||||
|
||||
return badges.Badge(
|
||||
badgeContent: Text(
|
||||
accountNotifications.toString(),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
showBadge: accountNotifications > 0,
|
||||
position: badges.BadgePosition.topEnd(
|
||||
top: -10,
|
||||
end: -6,
|
||||
),
|
||||
child: badges.Badge(
|
||||
showBadge: _accountStatus != null,
|
||||
badgeStyle: badges.BadgeStyle(badgeColor: statusBadgeColor),
|
||||
position: badges.BadgePosition.bottomEnd(
|
||||
bottom: 0,
|
||||
end: -2,
|
||||
),
|
||||
child: AccountAvatar(
|
||||
content: auth.userProfile.value!['avatar'],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return InkWell(
|
||||
child: !_isCollapsed
|
||||
? Row(
|
||||
children: [
|
||||
leading,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
auth.userProfile.value!['nick'],
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
).paddingOnly(left: 16),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
if (_accountStatus == null) {
|
||||
return Text(
|
||||
'loading'.tr,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
style: TextStyle(
|
||||
color: _unFocusColor,
|
||||
),
|
||||
).paddingOnly(left: 16);
|
||||
}
|
||||
final info = StatusProvider.determineStatus(
|
||||
_accountStatus!,
|
||||
);
|
||||
return Text(
|
||||
info.$3,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
style: TextStyle(
|
||||
color: _unFocusColor,
|
||||
),
|
||||
).paddingOnly(left: 16);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 20, vertical: 16)
|
||||
: leading.paddingSymmetric(horizontal: 20, vertical: 16),
|
||||
onTap: () {
|
||||
AppRouter.instance.goNamed('account');
|
||||
_closeDrawer();
|
||||
},
|
||||
onLongPress: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => AccountStatusAction(
|
||||
currentStatus: _accountStatus!.status,
|
||||
),
|
||||
).then((val) {
|
||||
if (val == true) _getStatus();
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _expandDrawer() {
|
||||
_drawerAnimationController.animateTo(1);
|
||||
}
|
||||
|
||||
void _collapseDrawer() {
|
||||
_drawerAnimationController.animateTo(0);
|
||||
}
|
||||
|
||||
void _closeDrawer() {
|
||||
_autoResize();
|
||||
rootScaffoldKey.currentState!.closeDrawer();
|
||||
}
|
||||
|
||||
void _autoResize() {
|
||||
if (AppTheme.isExtraLargeScreen(context)) {
|
||||
_expandDrawer();
|
||||
} else if (AppTheme.isLargeScreen(context)) {
|
||||
_collapseDrawer();
|
||||
} else {
|
||||
_drawerAnimationController.value = 1;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final AuthProvider auth = Get.find();
|
||||
if (auth.isAuthorized.value) _getStatus();
|
||||
Future.delayed(Duration.zero, () => _autoResize());
|
||||
_drawerAnimationController.addListener(() {
|
||||
if (_drawerAnimation.value > 180 && _isCollapsed) {
|
||||
setState(() => _isCollapsed = false);
|
||||
} else if (_drawerAnimation.value < 180 && !_isCollapsed) {
|
||||
setState(() => _isCollapsed = true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_drawerAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _drawerAnimation,
|
||||
builder: (context, child) {
|
||||
return Drawer(
|
||||
width: _drawerAnimation.value,
|
||||
backgroundColor:
|
||||
AppTheme.isLargeScreen(context) ? Colors.transparent : null,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildUserInfo().paddingSymmetric(vertical: 8),
|
||||
const Divider(thickness: 0.3, height: 1),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Wrap(
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
alignment: WrapAlignment.spaceAround,
|
||||
children: AppNavigation.destinations
|
||||
.map(
|
||||
(e) => Tooltip(
|
||||
message: e.label,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(8)),
|
||||
child: Icon(
|
||||
e.icon,
|
||||
size: 22,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
).paddingAll(16),
|
||||
onTap: () {
|
||||
AppRouter.instance.goNamed(e.page);
|
||||
_closeDrawer();
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
).paddingSymmetric(vertical: 8, horizontal: 12),
|
||||
),
|
||||
const Divider(thickness: 0.3, height: 1),
|
||||
Expanded(
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: AppNavigationRegion(
|
||||
isCollapsed: _isCollapsed,
|
||||
onSelected: () {
|
||||
_closeDrawer();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(thickness: 0.3, height: 1),
|
||||
Column(
|
||||
children: [
|
||||
if (_isCollapsed)
|
||||
Tooltip(
|
||||
message: 'expand'.tr,
|
||||
child: InkWell(
|
||||
child: const Icon(Icons.chevron_right, size: 20)
|
||||
.paddingSymmetric(
|
||||
horizontal: 28,
|
||||
vertical: 10,
|
||||
),
|
||||
onTap: () {
|
||||
_expandDrawer();
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
ListTile(
|
||||
minTileHeight: 0,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
),
|
||||
leading:
|
||||
const Icon(Icons.chevron_left, size: 20).paddingAll(2),
|
||||
title: Text('collapse'.tr),
|
||||
onTap: () {
|
||||
_collapseDrawer();
|
||||
},
|
||||
),
|
||||
],
|
||||
).paddingOnly(
|
||||
top: 8,
|
||||
bottom: math.max(8, MediaQuery.of(context).padding.bottom),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
65
lib/widgets/navigation/app_navigation_rail.dart
Normal file
65
lib/widgets/navigation/app_navigation_rail.dart
Normal file
@ -0,0 +1,65 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/widgets/navigation/app_navigation.dart';
|
||||
|
||||
class AppNavigationRail extends StatefulWidget {
|
||||
final int initialIndex;
|
||||
|
||||
const AppNavigationRail({super.key, this.initialIndex = 0});
|
||||
|
||||
@override
|
||||
State<AppNavigationRail> createState() => _AppNavigationRailState();
|
||||
}
|
||||
|
||||
class _AppNavigationRailState extends State<AppNavigationRail> {
|
||||
int? _currentIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialIndex >= 0) {
|
||||
_currentIndex = widget.initialIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NavigationRail(
|
||||
selectedIndex: _currentIndex,
|
||||
labelType: NavigationRailLabelType.selected,
|
||||
groupAlignment: -1,
|
||||
destinations: AppNavigation.destinations
|
||||
.sublist(0, AppNavigation.destinations.length - 1)
|
||||
.map(
|
||||
(x) => NavigationRailDestination(
|
||||
icon: x.icon,
|
||||
label: Text(x.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
trailing: Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: IconButton(
|
||||
icon: AppNavigation.destinations.last.icon,
|
||||
tooltip: AppNavigation.destinations.last.label,
|
||||
onPressed: () {
|
||||
setState(() => _currentIndex = null);
|
||||
AppRouter.instance.goNamed(AppNavigation.destinations.last.page);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
onDestinationSelected: (idx) {
|
||||
setState(() => _currentIndex = idx);
|
||||
AppRouter.instance.goNamed(AppNavigation.destinations[idx].page);
|
||||
},
|
||||
).paddingOnly(
|
||||
top: max(16, MediaQuery.of(context).padding.top),
|
||||
bottom: max(16, MediaQuery.of(context).padding.bottom),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,230 +0,0 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/channel.dart';
|
||||
import 'package:solian/providers/content/realm.dart';
|
||||
import 'package:solian/providers/navigation.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/auto_cache_image.dart';
|
||||
import 'package:solian/widgets/channel/channel_list.dart';
|
||||
|
||||
class AppNavigationRegion extends StatefulWidget {
|
||||
final bool isCollapsed;
|
||||
final Function onSelected;
|
||||
|
||||
const AppNavigationRegion({
|
||||
super.key,
|
||||
this.isCollapsed = false,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AppNavigationRegion> createState() => _AppNavigationRegionState();
|
||||
}
|
||||
|
||||
class _AppNavigationRegionState extends State<AppNavigationRegion> {
|
||||
bool _isTryingExit = false;
|
||||
|
||||
void _focusRealm(Realm item) {
|
||||
setState(
|
||||
() => Get.find<NavigationStateProvider>().focusedRealm.value = item,
|
||||
);
|
||||
}
|
||||
|
||||
void _unFocusRealm() {
|
||||
setState(
|
||||
() => Get.find<NavigationStateProvider>().focusedRealm.value = null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildRealmFocusAvatar() {
|
||||
final focusedRealm = Get.find<NavigationStateProvider>().focusedRealm.value;
|
||||
return GestureDetector(
|
||||
child: MouseRegion(
|
||||
child: AnimatedSwitcher(
|
||||
switchInCurve: Curves.fastOutSlowIn,
|
||||
switchOutCurve: Curves.fastOutSlowIn,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transitionBuilder: (child, animation) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: _isTryingExit
|
||||
? CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
).paddingSymmetric(
|
||||
vertical: 8,
|
||||
)
|
||||
: _buildEntryAvatar(focusedRealm!),
|
||||
),
|
||||
onEnter: (_) => setState(() => _isTryingExit = true),
|
||||
onExit: (_) => setState(() => _isTryingExit = false),
|
||||
),
|
||||
onTap: () => _unFocusRealm(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntryAvatar(Realm item) {
|
||||
return Hero(
|
||||
tag: Key('region-realm-avatar-${item.id}'),
|
||||
child: (item.avatar?.isNotEmpty ?? false)
|
||||
? AccountAvatar(content: item.avatar)
|
||||
: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: const Icon(
|
||||
Icons.workspaces,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
).paddingSymmetric(
|
||||
vertical: 8,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntry(BuildContext context, Realm item) {
|
||||
const padding = EdgeInsets.symmetric(horizontal: 20, vertical: 8);
|
||||
|
||||
if (widget.isCollapsed) {
|
||||
return InkWell(
|
||||
child: _buildEntryAvatar(item).paddingSymmetric(vertical: 8),
|
||||
onTap: () => _focusRealm(item),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
minTileHeight: 0,
|
||||
leading: _buildEntryAvatar(item),
|
||||
contentPadding: padding,
|
||||
title: Text(item.name),
|
||||
subtitle: Text(
|
||||
item.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () => _focusRealm(item),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final RealmProvider realms = Get.find();
|
||||
final ChannelProvider channels = Get.find();
|
||||
final AuthProvider auth = Get.find();
|
||||
final NavigationStateProvider navState = Get.find();
|
||||
|
||||
return Obx(
|
||||
() => PageTransitionSwitcher(
|
||||
transitionBuilder: (child, animation, secondaryAnimation) {
|
||||
return SharedAxisTransition(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: navState.focusedRealm.value == null
|
||||
? widget.isCollapsed
|
||||
? CustomScrollView(
|
||||
slivers: [
|
||||
const SliverPadding(padding: EdgeInsets.only(top: 16)),
|
||||
SliverList.builder(
|
||||
itemCount: realms.availableRealms.length,
|
||||
itemBuilder: (context, index) {
|
||||
final element = realms.availableRealms[index];
|
||||
return Tooltip(
|
||||
message: element.name,
|
||||
child: _buildEntry(context, element),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverList.builder(
|
||||
itemCount: realms.availableRealms.length,
|
||||
itemBuilder: (context, index) {
|
||||
final element = realms.availableRealms[index];
|
||||
return _buildEntry(context, element);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
if (!widget.isCollapsed &&
|
||||
(navState.focusedRealm.value!.banner?.isNotEmpty ??
|
||||
false))
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 7,
|
||||
child: AutoCacheImage(
|
||||
ServiceFinder.buildUrl(
|
||||
'uc',
|
||||
'/attachments/${navState.focusedRealm.value!.banner}',
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
if (widget.isCollapsed)
|
||||
Tooltip(
|
||||
message: navState.focusedRealm.value!.name,
|
||||
child: _buildRealmFocusAvatar().paddingOnly(
|
||||
top: 24,
|
||||
bottom: 8,
|
||||
),
|
||||
)
|
||||
else
|
||||
ListTile(
|
||||
minTileHeight: 0,
|
||||
tileColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
leading: _buildRealmFocusAvatar(),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 8),
|
||||
title: Text(navState.focusedRealm.value!.name),
|
||||
subtitle: Text(
|
||||
navState.focusedRealm.value!.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Obx(
|
||||
() => ChannelListWidget(
|
||||
useReplace: true,
|
||||
channels: channels.availableChannels
|
||||
.where((x) =>
|
||||
x.realm?.id == navState.focusedRealm.value?.id)
|
||||
.toList(),
|
||||
isCollapsed: widget.isCollapsed,
|
||||
selfId: auth.userProfile.value!['id'],
|
||||
noCategory: true,
|
||||
onSelected: (_) => widget.onSelected(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
92
lib/widgets/navigation/realm_switcher.dart
Normal file
92
lib/widgets/navigation/realm_switcher.dart
Normal file
@ -0,0 +1,92 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/providers/content/realm.dart';
|
||||
import 'package:solian/providers/navigation.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
|
||||
class RealmSwitcher extends StatelessWidget {
|
||||
const RealmSwitcher({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final realms = Get.find<RealmProvider>();
|
||||
final navState = Get.find<NavigationStateProvider>();
|
||||
|
||||
return Obx(() {
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<Realm?>(
|
||||
iconStyleData: const IconStyleData(iconSize: 0),
|
||||
isExpanded: true,
|
||||
hint: Text(
|
||||
'Realm Region',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
items: [null, ...realms.availableRealms]
|
||||
.map((Realm? item) => DropdownMenuItem<Realm?>(
|
||||
value: item,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (item != null)
|
||||
AccountAvatar(
|
||||
content: item.avatar,
|
||||
radius: 14,
|
||||
fallbackWidget: const Icon(
|
||||
Icons.workspaces,
|
||||
size: 16,
|
||||
),
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
radius: 14,
|
||||
child: const Icon(
|
||||
Icons.public,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item?.name ?? 'global'.tr,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
value: navState.focusedRealm.value,
|
||||
onChanged: (Realm? value) {
|
||||
navState.focusedRealm.value = value;
|
||||
},
|
||||
buttonStyleData: ButtonStyleData(
|
||||
height: 48,
|
||||
width: max(200, MediaQuery.of(context).size.width * 0.4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
),
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 48,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
@ -29,7 +29,7 @@ class _PostEditorThumbnailDialogState extends State<PostEditorThumbnailDialog> {
|
||||
_attachmentController.text = value.toString();
|
||||
});
|
||||
|
||||
widget.controller.thumbnail.value = value;
|
||||
widget.controller.thumbnail.value = value.isEmpty ? null : value;
|
||||
},
|
||||
initialAttachments: const [],
|
||||
onRemove: (_) {},
|
||||
@ -91,7 +91,8 @@ class _PostEditorThumbnailDialogState extends State<PostEditorThumbnailDialog> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.controller.thumbnail.value = _attachmentController.text;
|
||||
final text = _attachmentController.text;
|
||||
widget.controller.thumbnail.value = text.isEmpty ? null : text;
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('confirm'.tr),
|
||||
|
@ -12,6 +12,7 @@ import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/screens/posts/post_editor.dart';
|
||||
import 'package:solian/widgets/reports/abuse_report.dart';
|
||||
|
||||
class PostAction extends StatefulWidget {
|
||||
final Post item;
|
||||
@ -149,6 +150,23 @@ class _PostActionState extends State<PostAction> {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Icons.flag),
|
||||
title: Text('report'.tr),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AbuseReportDialog(
|
||||
resourceId: 'post:${widget.item.id}',
|
||||
),
|
||||
).then((status) {
|
||||
if (status == true) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!widget.noReact)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
|
@ -18,6 +18,7 @@ import 'package:solian/widgets/link_expansion.dart';
|
||||
import 'package:solian/widgets/markdown_text_content.dart';
|
||||
import 'package:solian/widgets/posts/post_tags.dart';
|
||||
import 'package:solian/widgets/posts/post_quick_action.dart';
|
||||
import 'package:solian/widgets/relative_date.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
import 'package:timeago/timeago.dart' show format;
|
||||
|
||||
@ -69,360 +70,6 @@ class _PostItemState extends State<PostItem> {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
Widget _buildDate() {
|
||||
if (widget.isFullDate) {
|
||||
return Text(DateFormat('y/M/d HH:mm')
|
||||
.format(item.publishedAt?.toLocal() ?? DateTime.now()));
|
||||
} else {
|
||||
return Text(
|
||||
format(
|
||||
item.publishedAt?.toLocal() ?? DateTime.now(),
|
||||
locale: 'en_short',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildThumbnail() {
|
||||
if (widget.item.body['thumbnail'] == null) return const SizedBox.shrink();
|
||||
final border = BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.3,
|
||||
);
|
||||
return Container(
|
||||
decoration: BoxDecoration(border: Border(top: border, bottom: border)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: AttachmentSelfContainedEntry(
|
||||
rid: widget.item.body['thumbnail'],
|
||||
parentId: 'p${item.id}-thumbnail',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.isCompact)
|
||||
AccountAvatar(
|
||||
content: item.author.avatar,
|
||||
radius: 10,
|
||||
).paddingOnly(left: 2, top: 1),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.author.nick,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
_buildDate().paddingOnly(left: 4),
|
||||
],
|
||||
),
|
||||
if (item.body['title'] != null)
|
||||
Text(
|
||||
item.body['title'],
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium!
|
||||
.copyWith(fontSize: 15),
|
||||
),
|
||||
if (item.body['description'] != null)
|
||||
Text(
|
||||
item.body['description'],
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
).paddingOnly(left: widget.isCompact ? 6 : 12),
|
||||
),
|
||||
if (widget.item.type == 'article')
|
||||
Badge(
|
||||
label: Text('article'.tr),
|
||||
).paddingOnly(top: 3),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderDivider() {
|
||||
if (item.body['description'] != null || item.body['title'] != null) {
|
||||
return const Divider(thickness: 0.3, height: 1).paddingSymmetric(
|
||||
vertical: 8,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
List<String> labels = List.empty(growable: true);
|
||||
if (widget.item.editedAt != null) {
|
||||
labels.add('postEdited'.trParams({
|
||||
'date': DateFormat('yy/M/d HH:mm').format(item.editedAt!.toLocal()),
|
||||
}));
|
||||
}
|
||||
if (widget.item.realm != null) {
|
||||
labels.add('postInRealm'.trParams({
|
||||
'realm': widget.item.realm!.alias,
|
||||
}));
|
||||
}
|
||||
|
||||
List<Widget> widgets = List.empty(growable: true);
|
||||
|
||||
if (widget.item.tags?.isNotEmpty ?? false) {
|
||||
widgets.add(PostTagsList(tags: widget.item.tags!));
|
||||
}
|
||||
if (labels.isNotEmpty) {
|
||||
widgets.add(Text(
|
||||
labels.join(' · '),
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _unFocusColor,
|
||||
),
|
||||
));
|
||||
}
|
||||
if (widget.item.pinnedAt != null) {
|
||||
widgets.add(Text(
|
||||
'postPinned'.tr,
|
||||
style: TextStyle(fontSize: 12, color: _unFocusColor),
|
||||
));
|
||||
}
|
||||
|
||||
if (widgets.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widgets,
|
||||
).paddingOnly(top: 4);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildReply(BuildContext context) {
|
||||
return OpenContainer(
|
||||
tappable: widget.isClickable || widget.isOverrideEmbedClickable,
|
||||
closedBuilder: (_, openContainer) => Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FaIcon(
|
||||
FontAwesomeIcons.reply,
|
||||
size: 16,
|
||||
color: _unFocusColor,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'postRepliedNotify'.trParams(
|
||||
{'username': '@${widget.item.replyTo!.author.name}'},
|
||||
),
|
||||
style: TextStyle(color: _unFocusColor),
|
||||
).paddingOnly(left: 6),
|
||||
),
|
||||
],
|
||||
).paddingOnly(left: 12),
|
||||
Card(
|
||||
elevation: 1,
|
||||
child: PostItem(
|
||||
item: widget.item.replyTo!,
|
||||
isCompact: true,
|
||||
attachmentParent: widget.item.id.toString(),
|
||||
).paddingSymmetric(vertical: 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
openBuilder: (_, __) => TitleShell(
|
||||
title: 'postDetail'.tr,
|
||||
child: PostDetailScreen(
|
||||
id: widget.item.replyTo!.id.toString(),
|
||||
post: widget.item.replyTo!,
|
||||
),
|
||||
),
|
||||
closedElevation: 0,
|
||||
openElevation: 0,
|
||||
closedColor:
|
||||
widget.backgroundColor ?? Theme.of(context).colorScheme.surface,
|
||||
openColor: Theme.of(context).colorScheme.surface,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRepost(BuildContext context) {
|
||||
return OpenContainer(
|
||||
tappable: widget.isClickable || widget.isOverrideEmbedClickable,
|
||||
closedBuilder: (_, openContainer) => Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FaIcon(
|
||||
FontAwesomeIcons.retweet,
|
||||
size: 16,
|
||||
color: _unFocusColor,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'postRepostedNotify'.trParams(
|
||||
{'username': '@${widget.item.repostTo!.author.name}'},
|
||||
),
|
||||
style: TextStyle(color: _unFocusColor),
|
||||
).paddingOnly(left: 6),
|
||||
),
|
||||
],
|
||||
).paddingOnly(left: 12),
|
||||
Card(
|
||||
elevation: 1,
|
||||
child: PostItem(
|
||||
item: widget.item.repostTo!,
|
||||
isCompact: true,
|
||||
attachmentParent: widget.item.id.toString(),
|
||||
).paddingSymmetric(vertical: 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
openBuilder: (_, __) => TitleShell(
|
||||
title: 'postDetail'.tr,
|
||||
child: PostDetailScreen(
|
||||
id: widget.item.repostTo!.id.toString(),
|
||||
post: widget.item.repostTo!,
|
||||
),
|
||||
),
|
||||
closedElevation: 0,
|
||||
openElevation: 0,
|
||||
closedColor:
|
||||
widget.backgroundColor ?? Theme.of(context).colorScheme.surface,
|
||||
openColor: Theme.of(context).colorScheme.surface,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAttachments() {
|
||||
final List<String> attachments = item.body['attachments'] is List
|
||||
? List.from(item.body['attachments']?.whereType<String>())
|
||||
: List.empty();
|
||||
|
||||
if (attachments.length > 3) {
|
||||
return AttachmentList(
|
||||
parentId: widget.item.id.toString(),
|
||||
attachmentsId: attachments,
|
||||
autoload: false,
|
||||
isGrid: true,
|
||||
).paddingOnly(left: 36, top: 4, bottom: 4);
|
||||
} else if (attachments.length > 1 || AppTheme.isLargeScreen(context)) {
|
||||
return AttachmentList(
|
||||
parentId: widget.item.id.toString(),
|
||||
attachmentsId: attachments,
|
||||
autoload: false,
|
||||
isColumn: true,
|
||||
).paddingOnly(left: 60, right: 24);
|
||||
} else {
|
||||
return AttachmentList(
|
||||
flatMaxHeight: MediaQuery.of(context).size.width,
|
||||
parentId: widget.item.id.toString(),
|
||||
attachmentsId: attachments,
|
||||
autoload: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFeaturedReply() {
|
||||
if ((widget.item.metric?.replyCount ?? 0) == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final List<String> attachments = item.body['attachments'] is List
|
||||
? List.from(item.body['attachments']?.whereType<String>())
|
||||
: List.empty();
|
||||
final unFocusColor =
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
||||
return FutureBuilder(
|
||||
future: Get.find<PostProvider>().listPostFeaturedReply(
|
||||
widget.item.id.toString(),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: snapshot.data!
|
||||
.map(
|
||||
(x) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountAvatar(content: x.author.avatar, radius: 10),
|
||||
const Gap(6),
|
||||
Text(
|
||||
x.author.nick,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Gap(6),
|
||||
Text(
|
||||
format(
|
||||
x.publishedAt?.toLocal() ?? DateTime.now(),
|
||||
locale: 'en_short',
|
||||
),
|
||||
).paddingOnly(top: 0.5),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MarkdownTextContent(
|
||||
content: x.body['content'],
|
||||
parentId: 'p${item.id}-featured-reply${x.id}',
|
||||
),
|
||||
if (x.body['attachments'] is List &&
|
||||
x.body['attachments'].length > 0)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.file_copy,
|
||||
size: 15,
|
||||
color: unFocusColor,
|
||||
).paddingOnly(right: 5),
|
||||
Text(
|
||||
'attachmentHint'.trParams(
|
||||
{
|
||||
'count': x.body['attachments'].length
|
||||
.toString()
|
||||
},
|
||||
),
|
||||
style: TextStyle(color: unFocusColor),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 12, vertical: 8),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(
|
||||
duration: 300.ms,
|
||||
curve: Curves.easeIn,
|
||||
)
|
||||
.paddingOnly(
|
||||
top: (attachments.length == 1 && !AppTheme.isLargeScreen(context))
|
||||
? 10
|
||||
: 6,
|
||||
left:
|
||||
(attachments.length == 1 && !AppTheme.isLargeScreen(context))
|
||||
? 24
|
||||
: 60,
|
||||
right: 16,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
double _contentHeight = 0;
|
||||
|
||||
@override
|
||||
@ -436,9 +83,15 @@ class _PostItemState extends State<PostItem> {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildThumbnail().paddingOnly(bottom: 8),
|
||||
_buildHeader().paddingSymmetric(horizontal: 12),
|
||||
_buildHeaderDivider().paddingSymmetric(horizontal: 12),
|
||||
_PostThumbnail(
|
||||
rid: item.body['thumbnail'],
|
||||
parentId: widget.item.id.toString(),
|
||||
).paddingOnly(bottom: 8),
|
||||
_PostHeaderWidget(
|
||||
isCompact: widget.isCompact,
|
||||
item: item,
|
||||
).paddingSymmetric(horizontal: 12),
|
||||
_PostHeaderDividerWidget(item: item).paddingSymmetric(horizontal: 12),
|
||||
Stack(
|
||||
children: [
|
||||
SizedContainer(
|
||||
@ -448,10 +101,14 @@ class _PostItemState extends State<PostItem> {
|
||||
onChange: (size) {
|
||||
setState(() => _contentHeight = size.height);
|
||||
},
|
||||
child: MarkdownTextContent(
|
||||
parentId: 'p${item.id}',
|
||||
content: item.body['content'],
|
||||
isSelectable: widget.isContentSelectable,
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: MarkdownTextContent(
|
||||
parentId: 'p${item.id}',
|
||||
content: item.body['content'],
|
||||
isAutoWarp: item.type == 'story',
|
||||
isSelectable: widget.isContentSelectable,
|
||||
),
|
||||
).paddingOnly(
|
||||
left: 16,
|
||||
right: 12,
|
||||
@ -460,36 +117,22 @@ class _PostItemState extends State<PostItem> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_contentHeight >= 80 && !widget.isFullContent)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.surface
|
||||
.withOpacity(0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_contentHeight >= 80 && !widget.isFullContent)
|
||||
Opacity(
|
||||
opacity: 0.8,
|
||||
child: InkWell(child: Text('readMore'.tr)),
|
||||
).paddingOnly(
|
||||
left: 12,
|
||||
top: 4,
|
||||
),
|
||||
LinkExpansion(content: item.body['content']).paddingOnly(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 4,
|
||||
),
|
||||
_buildFooter().paddingOnly(left: 16),
|
||||
_PostFooterWidget(item: item).paddingOnly(left: 16),
|
||||
if (attachments.isNotEmpty)
|
||||
Row(
|
||||
children: [
|
||||
@ -515,7 +158,10 @@ class _PostItemState extends State<PostItem> {
|
||||
closedBuilder: (_, openContainer) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildThumbnail().paddingOnly(bottom: 4),
|
||||
_PostThumbnail(
|
||||
rid: item.body['thumbnail'],
|
||||
parentId: widget.item.id.toString(),
|
||||
).paddingOnly(bottom: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -537,8 +183,11 @@ class _PostItemState extends State<PostItem> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHeader(),
|
||||
_buildHeaderDivider(),
|
||||
_PostHeaderWidget(
|
||||
isCompact: widget.isCompact,
|
||||
item: item,
|
||||
),
|
||||
_PostHeaderDividerWidget(item: item),
|
||||
Stack(
|
||||
children: [
|
||||
SizedContainer(
|
||||
@ -549,52 +198,60 @@ class _PostItemState extends State<PostItem> {
|
||||
onChange: (size) {
|
||||
setState(() => _contentHeight = size.height);
|
||||
},
|
||||
child: MarkdownTextContent(
|
||||
parentId: 'p${item.id}-embed',
|
||||
content: item.body['content'],
|
||||
isSelectable: widget.isContentSelectable,
|
||||
isLargeText: item.type == 'article' &&
|
||||
widget.isFullContent,
|
||||
).paddingOnly(left: 12, right: 8),
|
||||
),
|
||||
),
|
||||
if (_contentHeight >= 320 && !widget.isFullContent)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
height: 320,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surface,
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.surface
|
||||
.withOpacity(0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: MarkdownTextContent(
|
||||
parentId: 'p${item.id}-embed',
|
||||
content: item.body['content'],
|
||||
isAutoWarp: item.type == 'story',
|
||||
isSelectable: widget.isContentSelectable,
|
||||
isLargeText: item.type == 'article' &&
|
||||
widget.isFullContent,
|
||||
).paddingOnly(left: 12, right: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_contentHeight >= 320 && !widget.isFullContent)
|
||||
Opacity(
|
||||
opacity: 0.8,
|
||||
child: InkWell(child: Text('readMore'.tr)),
|
||||
).paddingOnly(
|
||||
left: 12,
|
||||
top: 4,
|
||||
),
|
||||
if (widget.item.replyTo != null && widget.isShowEmbed)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: _buildReply(context),
|
||||
child: _PostEmbedWidget(
|
||||
isClickable: widget.isClickable,
|
||||
isOverrideEmbedClickable:
|
||||
widget.isOverrideEmbedClickable,
|
||||
item: widget.item.replyTo!,
|
||||
username: widget.item.replyTo!.author.name,
|
||||
hintText: 'postRepliedNotify',
|
||||
icon: FontAwesomeIcons.reply,
|
||||
id: widget.item.replyTo!.id.toString(),
|
||||
),
|
||||
),
|
||||
if (widget.item.repostTo != null && widget.isShowEmbed)
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: _buildRepost(context),
|
||||
child: _PostEmbedWidget(
|
||||
isClickable: widget.isClickable,
|
||||
isOverrideEmbedClickable:
|
||||
widget.isOverrideEmbedClickable,
|
||||
item: widget.item.repostTo!,
|
||||
username: widget.item.repostTo!.author.name,
|
||||
hintText: 'postRepostedNotify',
|
||||
icon: FontAwesomeIcons.retweet,
|
||||
id: widget.item.repostTo!.id.toString(),
|
||||
),
|
||||
),
|
||||
_buildFooter().paddingOnly(left: 12),
|
||||
_PostFooterWidget(item: item).paddingOnly(left: 12),
|
||||
LinkExpansion(content: item.body['content'])
|
||||
.paddingOnly(top: 4),
|
||||
],
|
||||
@ -610,8 +267,8 @@ class _PostItemState extends State<PostItem> {
|
||||
right: 16,
|
||||
left: 16,
|
||||
),
|
||||
_buildAttachments(),
|
||||
if (widget.showFeaturedReply) _buildFeaturedReply(),
|
||||
_PostAttachmentWidget(item: item),
|
||||
if (widget.showFeaturedReply) _PostFeaturedReplyWidget(item: item),
|
||||
if (widget.isShowReply || widget.isReactable)
|
||||
PostQuickAction(
|
||||
isShowReply: widget.isShowReply,
|
||||
@ -647,13 +304,406 @@ class _PostItemState extends State<PostItem> {
|
||||
),
|
||||
closedElevation: 0,
|
||||
openElevation: 0,
|
||||
closedColor:
|
||||
widget.backgroundColor ?? Theme.of(context).colorScheme.surface,
|
||||
closedColor: Colors.transparent,
|
||||
openColor: Theme.of(context).colorScheme.surface,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostFeaturedReplyWidget extends StatelessWidget {
|
||||
final Post item;
|
||||
|
||||
const _PostFeaturedReplyWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLargeScreen = AppTheme.isLargeScreen(context);
|
||||
final unFocusColor =
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
||||
|
||||
if ((item.metric?.replyCount ?? 0) == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final List<String> attachments = item.body['attachments'] is List
|
||||
? List.from(item.body['attachments']?.whereType<String>())
|
||||
: List.empty();
|
||||
|
||||
return FutureBuilder(
|
||||
future:
|
||||
Get.find<PostProvider>().listPostFeaturedReply(item.id.toString()),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: snapshot.data!
|
||||
.map(
|
||||
(reply) => ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: OpenContainer(
|
||||
closedBuilder: (_, openContainer) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountAvatar(
|
||||
content: reply.author.avatar,
|
||||
radius: 10,
|
||||
),
|
||||
const Gap(6),
|
||||
Text(
|
||||
reply.author.nick,
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Gap(6),
|
||||
Text(
|
||||
format(
|
||||
reply.publishedAt?.toLocal() ?? DateTime.now(),
|
||||
locale: 'en_short',
|
||||
),
|
||||
).paddingOnly(top: 0.5),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MarkdownTextContent(
|
||||
isAutoWarp: reply.type == 'story',
|
||||
content: reply.body['content'],
|
||||
parentId:
|
||||
'p${item.id}-featured-reply${reply.id}',
|
||||
),
|
||||
if (reply.body['attachments'] is List &&
|
||||
reply.body['attachments'].isNotEmpty)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.file_copy,
|
||||
size: 15,
|
||||
color: unFocusColor,
|
||||
).paddingOnly(right: 5),
|
||||
Text(
|
||||
'attachmentHint'.trParams(
|
||||
{
|
||||
'count': reply
|
||||
.body['attachments'].length
|
||||
.toString(),
|
||||
},
|
||||
),
|
||||
style: TextStyle(color: unFocusColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 12, vertical: 8),
|
||||
openBuilder: (_, __) => TitleShell(
|
||||
title: 'postDetail'.tr,
|
||||
child: PostDetailScreen(
|
||||
id: reply.id.toString(),
|
||||
post: reply,
|
||||
),
|
||||
),
|
||||
closedElevation: 0,
|
||||
openElevation: 0,
|
||||
closedColor:
|
||||
Theme.of(context).colorScheme.surfaceContainer,
|
||||
openColor: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(
|
||||
duration: 300.ms,
|
||||
curve: Curves.easeIn,
|
||||
)
|
||||
.paddingOnly(
|
||||
top: (attachments.length == 1 && !isLargeScreen) ? 10 : 6,
|
||||
left: (attachments.length == 1 && !isLargeScreen) ? 24 : 60,
|
||||
right: 16,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostAttachmentWidget extends StatelessWidget {
|
||||
final Post item;
|
||||
|
||||
const _PostAttachmentWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLargeScreen = AppTheme.isLargeScreen(context);
|
||||
|
||||
final List<String> attachments = item.body['attachments'] is List
|
||||
? List.from(item.body['attachments']?.whereType<String>())
|
||||
: List.empty();
|
||||
|
||||
if (attachments.length > 3) {
|
||||
return AttachmentList(
|
||||
parentId: item.id.toString(),
|
||||
attachmentsId: attachments,
|
||||
autoload: false,
|
||||
isGrid: true,
|
||||
).paddingOnly(left: 36, top: 4, bottom: 4);
|
||||
} else if (attachments.length > 1 || isLargeScreen) {
|
||||
return AttachmentList(
|
||||
parentId: item.id.toString(),
|
||||
attachmentsId: attachments,
|
||||
autoload: false,
|
||||
isColumn: true,
|
||||
).paddingOnly(left: 60, right: 24, top: 4, bottom: 4);
|
||||
} else {
|
||||
return AttachmentList(
|
||||
flatMaxHeight: MediaQuery.of(context).size.width,
|
||||
parentId: item.id.toString(),
|
||||
attachmentsId: attachments,
|
||||
autoload: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PostEmbedWidget extends StatelessWidget {
|
||||
final bool isClickable;
|
||||
final bool isOverrideEmbedClickable;
|
||||
final Post item;
|
||||
final String username;
|
||||
final String hintText;
|
||||
final IconData icon;
|
||||
final String id;
|
||||
|
||||
const _PostEmbedWidget({
|
||||
required this.isClickable,
|
||||
required this.isOverrideEmbedClickable,
|
||||
required this.item,
|
||||
required this.username,
|
||||
required this.hintText,
|
||||
required this.icon,
|
||||
required this.id,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unFocusColor =
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
||||
|
||||
return OpenContainer(
|
||||
tappable: isClickable || isOverrideEmbedClickable,
|
||||
closedBuilder: (_, openContainer) => Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
FaIcon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: unFocusColor,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hintText.trParams(
|
||||
{'username': '@$username'},
|
||||
),
|
||||
style: TextStyle(color: unFocusColor),
|
||||
).paddingOnly(left: 6),
|
||||
),
|
||||
],
|
||||
).paddingOnly(left: 12),
|
||||
Card(
|
||||
elevation: 1,
|
||||
child: PostItem(
|
||||
item: item,
|
||||
isCompact: true,
|
||||
attachmentParent: id,
|
||||
).paddingSymmetric(vertical: 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
openBuilder: (_, __) => TitleShell(
|
||||
title: 'postDetail'.tr,
|
||||
child: PostDetailScreen(
|
||||
id: id,
|
||||
post: item,
|
||||
),
|
||||
),
|
||||
closedElevation: 0,
|
||||
openElevation: 0,
|
||||
closedColor: Colors.transparent,
|
||||
openColor: Theme.of(context).colorScheme.surface,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostHeaderDividerWidget extends StatelessWidget {
|
||||
final Post item;
|
||||
|
||||
const _PostHeaderDividerWidget({
|
||||
required this.item,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (item.body['description'] != null || item.body['title'] != null) {
|
||||
return const Divider(thickness: 0.3, height: 1).paddingSymmetric(
|
||||
vertical: 8,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class _PostFooterWidget extends StatelessWidget {
|
||||
final Post item;
|
||||
|
||||
const _PostFooterWidget({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unFocusColor =
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
||||
|
||||
List<String> labels = List.empty(growable: true);
|
||||
if (item.editedAt != null) {
|
||||
labels.add('postEdited'.trParams({
|
||||
'date': DateFormat('yy/M/d HH:mm').format(item.editedAt!.toLocal()),
|
||||
}));
|
||||
}
|
||||
if (item.realm != null) {
|
||||
labels.add('postInRealm'.trParams({
|
||||
'realm': item.realm!.alias,
|
||||
}));
|
||||
}
|
||||
|
||||
List<Widget> widgets = List.empty(growable: true);
|
||||
|
||||
if (item.tags?.isNotEmpty ?? false) {
|
||||
widgets.add(PostTagsList(tags: item.tags!));
|
||||
}
|
||||
if (labels.isNotEmpty) {
|
||||
widgets.add(Text(
|
||||
labels.join(' · '),
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: unFocusColor,
|
||||
),
|
||||
));
|
||||
}
|
||||
if (item.pinnedAt != null) {
|
||||
widgets.add(Text(
|
||||
'postPinned'.tr,
|
||||
style: TextStyle(fontSize: 12, color: unFocusColor),
|
||||
));
|
||||
}
|
||||
|
||||
if (widgets.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: widgets,
|
||||
).paddingOnly(top: 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PostHeaderWidget extends StatelessWidget {
|
||||
final bool isCompact;
|
||||
final Post item;
|
||||
|
||||
const _PostHeaderWidget({
|
||||
required this.isCompact,
|
||||
required this.item,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isCompact)
|
||||
AccountAvatar(
|
||||
content: item.author.avatar,
|
||||
radius: 10,
|
||||
).paddingOnly(left: 2, top: 1),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.author.nick,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
RelativeDate(item.publishedAt?.toLocal() ?? DateTime.now())
|
||||
.paddingOnly(left: 4),
|
||||
],
|
||||
),
|
||||
if (item.body['title'] != null)
|
||||
Text(
|
||||
item.body['title'],
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium!
|
||||
.copyWith(fontSize: 15),
|
||||
),
|
||||
if (item.body['description'] != null)
|
||||
Text(
|
||||
item.body['description'],
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
).paddingOnly(left: isCompact ? 6 : 12),
|
||||
),
|
||||
if (item.type == 'article')
|
||||
Badge(
|
||||
label: Text('article'.tr),
|
||||
).paddingOnly(top: 3),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostThumbnail extends StatelessWidget {
|
||||
final String parentId;
|
||||
final String? rid;
|
||||
|
||||
const _PostThumbnail({required this.parentId, required this.rid});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (rid?.isEmpty ?? true) return const SizedBox.shrink();
|
||||
final border = BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.3,
|
||||
);
|
||||
return Container(
|
||||
decoration: BoxDecoration(border: Border(top: border, bottom: border)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: AttachmentSelfContainedEntry(
|
||||
rid: rid!,
|
||||
parentId: 'p$parentId-thumbnail',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _OnWidgetSizeChange = void Function(Size size);
|
||||
|
||||
class _MeasureSizeRenderObject extends RenderProxyBox {
|
||||
|
@ -27,7 +27,7 @@ class PostTagsList extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
AppRouter.instance.pushNamed('feedSearch', queryParameters: {
|
||||
AppRouter.instance.pushNamed('postSearch', queryParameters: {
|
||||
'tag': x.alias,
|
||||
});
|
||||
},
|
||||
|
23
lib/widgets/relative_date.dart
Normal file
23
lib/widgets/relative_date.dart
Normal file
@ -0,0 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:timeago/timeago.dart';
|
||||
|
||||
class RelativeDate extends StatelessWidget {
|
||||
final DateTime date;
|
||||
final bool isFull;
|
||||
|
||||
const RelativeDate(this.date, {super.key, this.isFull = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isFull) {
|
||||
return Text(DateFormat('y/M/d HH:mm').format(date));
|
||||
}
|
||||
return Text(
|
||||
format(
|
||||
date,
|
||||
locale: 'en_short',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
107
lib/widgets/reports/abuse_report.dart
Normal file
107
lib/widgets/reports/abuse_report.dart
Normal file
@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/exceptions/request.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
|
||||
class AbuseReportDialog extends StatefulWidget {
|
||||
final String? resourceId;
|
||||
|
||||
const AbuseReportDialog({super.key, this.resourceId});
|
||||
|
||||
@override
|
||||
State<AbuseReportDialog> createState() => _AbuseReportDialogState();
|
||||
}
|
||||
|
||||
class _AbuseReportDialogState extends State<AbuseReportDialog> {
|
||||
final TextEditingController _resourceController = TextEditingController();
|
||||
final TextEditingController _reasonController = TextEditingController();
|
||||
|
||||
bool _isBusy = false;
|
||||
|
||||
Future<void> _submit() async {
|
||||
final auth = Get.find<AuthProvider>();
|
||||
if (!auth.isAuthorized.value) return;
|
||||
final client = await auth.configureClient('id');
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final resp = await client.post('/reports/abuse', {
|
||||
'resource': _resourceController.text,
|
||||
'reason': _reasonController.text,
|
||||
});
|
||||
|
||||
setState(() => _isBusy = false);
|
||||
|
||||
if (resp.statusCode != 200) {
|
||||
context.showErrorDialog(RequestException(resp));
|
||||
} else {
|
||||
context.showSnackbar('reportSubmitted'.tr);
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if (widget.resourceId != null) {
|
||||
_resourceController.text = widget.resourceId!;
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_resourceController.dispose();
|
||||
_reasonController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('reportAbuse'.tr),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _resourceController,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'reportAbuseResource'.tr,
|
||||
enabled: widget.resourceId == null,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
TextField(
|
||||
controller: _reasonController,
|
||||
textInputAction: TextInputAction.newline,
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'reportAbuseReason'.tr,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isBusy
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('cancel'.tr),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _submit(),
|
||||
child: Text('okay'.tr),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
48
lib/widgets/root_container.dart
Normal file
48
lib/widgets/root_container.dart
Normal file
@ -0,0 +1,48 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
|
||||
class RootContainer extends StatelessWidget {
|
||||
final Widget? child;
|
||||
|
||||
const RootContainer({super.key, this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: PlatformInfo.isWeb
|
||||
? Future.value(null)
|
||||
: getApplicationDocumentsDirectory(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final path = '${snapshot.data!.path}/app_background_image';
|
||||
final file = File(path);
|
||||
if (file.existsSync()) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
backgroundBlendMode: BlendMode.darken,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
image: DecorationImage(
|
||||
opacity: 0.2,
|
||||
image: FileImage(file),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -1,14 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/widgets/root_container.dart';
|
||||
|
||||
class EmptyPagePlaceholder extends StatelessWidget {
|
||||
const EmptyPagePlaceholder({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
return RootContainer(
|
||||
child: Center(
|
||||
child: Image.asset('assets/logo.png', width: 80, height: 80),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: Image.asset('assets/logo.png', width: 80, height: 80),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -1,15 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SidebarPlaceholder extends StatelessWidget {
|
||||
const SidebarPlaceholder({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: const Center(
|
||||
child: Icon(Icons.menu_open, size: 50),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -17,6 +17,7 @@ import flutter_local_notifications
|
||||
import flutter_secure_storage_macos
|
||||
import flutter_webrtc
|
||||
import gal
|
||||
import in_app_review
|
||||
import livekit_client
|
||||
import macos_window_utils
|
||||
import media_kit_libs_macos_video
|
||||
@ -46,6 +47,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
||||
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
|
||||
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
|
||||
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
|
||||
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
|
||||
|
@ -158,7 +158,9 @@ PODS:
|
||||
- GoogleUtilities/UserDefaults (8.0.2):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- livekit_client (2.2.5):
|
||||
- in_app_review (0.2.0):
|
||||
- FlutterMacOS
|
||||
- livekit_client (2.2.6):
|
||||
- FlutterMacOS
|
||||
- WebRTC-SDK (= 125.6422.04)
|
||||
- macos_window_utils (1.0.0):
|
||||
@ -234,6 +236,7 @@ DEPENDENCIES:
|
||||
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`)
|
||||
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
|
||||
- livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
|
||||
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`)
|
||||
- media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`)
|
||||
@ -299,6 +302,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral
|
||||
gal:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
|
||||
in_app_review:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos
|
||||
livekit_client:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos
|
||||
macos_window_utils:
|
||||
@ -336,7 +341,7 @@ SPEC CHECKSUMS:
|
||||
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
|
||||
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
|
||||
device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720
|
||||
file_selector_macos: 54fdab7caa3ac3fc43c9fac4d7d8d231277f8cf2
|
||||
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
|
||||
Firebase: 9f574c08c2396885b5e7e100ed4293d956218af9
|
||||
firebase_analytics: a2d0d907566e4a48e27745317f05b4b7db85edd9
|
||||
firebase_core: c55630cdb8a01cf49eae741dd4bc8c93bdd546b8
|
||||
@ -359,7 +364,8 @@ SPEC CHECKSUMS:
|
||||
GoogleAppMeasurement: 6e49ffac7d3f2c3ded9cc663f912a13b67bbd0de
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
||||
livekit_client: be04a950a4b84b9dbc87507ffad5154fe75fa067
|
||||
in_app_review: a850789fad746e89bce03d4aeee8078b45a53fd0
|
||||
livekit_client: 98d09566e3a936b3402be8091ec3845556d36800
|
||||
macos_window_utils: 933f91f64805e2eb91a5bd057cf97cd097276663
|
||||
media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
|
||||
media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5
|
||||
@ -377,7 +383,7 @@ SPEC CHECKSUMS:
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
|
||||
sqlite3_flutter_libs: 5ca46c1a04eddfbeeb5b16566164aa7ad1616e7b
|
||||
url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399
|
||||
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
|
||||
wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
|
||||
WebRTC-SDK: c3d69a87e7185fad3568f6f3cff7c9ac5890acf3
|
||||
|
||||
|
@ -12,8 +12,6 @@
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
|
@ -47,10 +47,21 @@
|
||||
<string>MainMenu</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>zh_CN</string>
|
||||
<string>en</string>
|
||||
</array>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INStartCallIntent</string>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Allow you take photo/video for your message or post</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Allow you record audio for your message or post</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Allow you add photo to your message or post</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -10,8 +10,6 @@
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
|
52
pubspec.lock
52
pubspec.lock
@ -22,6 +22,14 @@ packages:
|
||||
description: dart
|
||||
source: sdk
|
||||
version: "0.3.2"
|
||||
action_slider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: action_slider
|
||||
sha256: fad0720cde9bf06c12594c15da17dba087556a3285875a91aee3d3a64a3072e2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -330,10 +338,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: desktop_drop
|
||||
sha256: d55a010fe46c8e8fcff4ea4b451a9ff84a162217bdb3b2a0aa1479776205e15d
|
||||
sha256: "03abf1c0443afdd1d65cf8fa589a2f01c67a11da56bbb06f6ea1de79d5628e94"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.4"
|
||||
version: "0.5.0"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -466,10 +474,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file_selector_macos
|
||||
sha256: f42eacb83b318e183b1ae24eead1373ab1334084404c8c16e0354f9a3e55d385
|
||||
sha256: cb284e267f8e2a45a904b5c094d2ba51d0aabfc20b1538ab786d9ef7dc2bf75c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
version: "0.9.4+1"
|
||||
file_selector_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -639,10 +647,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_app_update
|
||||
sha256: "2b83278d5cc807f543e623d5b466216316104335a4918d9cc4556f39985fe84a"
|
||||
sha256: "3650f57571e9f05d51f008f3fc9d556351910348f8011de7734b56fa74ccfee6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "3.1.1"
|
||||
flutter_background_service:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -743,10 +751,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
||||
sha256: a38f2f1b3c373d42bf08bd17d60e20d3c73abce7727607b4d085ec7d5acaa294
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.1"
|
||||
version: "0.14.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -759,10 +767,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_local_notifications
|
||||
sha256: c500d5d9e7e553f06b61877ca6b9c8b92c570a4c8db371038702e8ce57f8a50f
|
||||
sha256: "49eeef364fddb71515bc78d5a8c51435a68bccd6e4d68e25a942c5e47761ae71"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "17.2.2"
|
||||
version: "17.2.3"
|
||||
flutter_local_notifications_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1117,6 +1125,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1+1"
|
||||
in_app_review:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: in_app_review
|
||||
sha256: "99869244d09adc76af16bf8fd731dd13cef58ecafd5917847589c49f378cbb30"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.9"
|
||||
in_app_review_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: in_app_review_platform_interface
|
||||
sha256: fed2c755f2125caa9ae10495a3c163aa7fab5af3585a9c62ef4a6920c5b45f10
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.5"
|
||||
infinite_scroll_pagination:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1201,10 +1225,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: livekit_client
|
||||
sha256: "5df9b6f153b5f2c59fbf116b41e54597dfe8b2340b6630f7d8869887a9e58f44"
|
||||
sha256: "449f1f4f7688cc0d27a466d5b78c8973ec4bf2bbe93f79441f4fd118ecea61d7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.5"
|
||||
version: "2.2.6"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -2070,10 +2094,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de"
|
||||
sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
version: "3.2.1"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -2,7 +2,7 @@ name: solian
|
||||
description: "The Solar Network App"
|
||||
publish_to: "none"
|
||||
|
||||
version: 1.2.2+2
|
||||
version: 1.3.6+3
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.4 <4.0.0"
|
||||
@ -43,7 +43,7 @@ dependencies:
|
||||
protocol_handler: ^0.2.0
|
||||
markdown: ^7.2.2
|
||||
pasteboard: ^0.3.0
|
||||
desktop_drop: ^0.4.4
|
||||
desktop_drop: ^0.5.0
|
||||
badges: ^3.1.2
|
||||
flutter_card_swiper: ^7.0.1
|
||||
dismissible_page: ^1.0.2
|
||||
@ -82,13 +82,15 @@ dependencies:
|
||||
flutter_local_notifications: ^17.2.2
|
||||
flutter_app_update: ^3.1.0
|
||||
version: ^3.0.2
|
||||
action_slider: ^0.7.0
|
||||
in_app_review: ^2.0.9
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
flutter_lints: ^4.0.0
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
flutter_launcher_icons: ^0.14.0
|
||||
|
||||
build_runner: ^2.4.12
|
||||
flutter_native_splash: ^2.4.1
|
||||
|
Reference in New Issue
Block a user