Compare commits

...

32 Commits

Author SHA1 Message Date
c5258cb9ca 🚀 Launch 1.3.6+3 2024-10-06 19:57:17 +08:00
47c535910d 💄 Optimize the style with background image 2024-10-06 19:54:32 +08:00
66f2f33394 🐛 Bug fixes with background image 2024-10-06 19:41:44 +08:00
f5fbe1f483 Better theme & background image 2024-10-06 19:29:47 +08:00
fcf4dc7a2d ♻️ Use unified root container 2024-10-06 17:37:07 +08:00
43b7059957 🐛 Bug fixes and optimization 2024-10-06 17:31:44 +08:00
11c913af60 🚀 Launch 1.3.6+1 2024-10-06 11:34:12 +08:00
db8f0d63e1 🐛 Fix responsive chat issue 2024-10-06 11:12:54 +08:00
4036a79995 🐛 Fix some building time problem 2024-10-06 01:53:36 +08:00
859bbd09e0 🚀 Launch 1.3.0+1 2024-10-06 01:43:10 +08:00
60033fdef3 🐛 Fix platform specific bugs & crashes 2024-10-06 01:42:51 +08:00
9c3d181deb 📱 Optimize the call experience on landscape device 2024-10-06 01:25:10 +08:00
9e6829bd5a 📱 New layout for the landscape device 2024-10-06 01:17:49 +08:00
f50461a7f7 💄 Better chat list 2024-10-05 23:12:23 +08:00
147879e4d8 Better last message preview 2024-10-05 15:11:48 +08:00
f353c05cb5 💄 Better way to switch focused realm 2024-10-05 14:25:57 +08:00
ac60043ca7 🐛 Bug fixes 2024-10-05 03:38:30 +08:00
8d79274b0c 🐛 Fix dm channel display error with deleted user 2024-10-05 03:21:53 +08:00
ad4e4071fa ♻️ Use bottom navigation bar instead 2024-10-05 03:14:52 +08:00
c59f77c877 🐛 Fix windows rendering lack 2024-09-28 18:41:56 +08:00
16047a7d57 🚀 Launch 1.2.5+1 2024-09-27 00:20:04 +08:00
fdc68fc5e1 💄 Optimize attachment editor controls 2024-09-27 00:12:30 +08:00
bbee825cf4 ♻️ Refactor profile page code 2024-09-27 00:02:08 +08:00
2673c11046 Able to block anyone
💄 Optimize user profile page
2024-09-26 23:47:19 +08:00
3ac6822ab6 🚀 Launch 1.2.4+1 2024-09-24 22:40:54 +08:00
7a5fd2e468 In app rating 2024-09-24 22:10:45 +08:00
e1ddd22e4e 🚀 Launch 1.2.3+2 2024-09-23 23:34:40 +08:00
22b2ae32e9 Featured replies clickable 2024-09-23 23:34:25 +08:00
9d5c452eae 🐛 Fix overflow in content 2024-09-23 23:20:01 +08:00
0fdb1e4ead 💫 Improve loading image animation 2024-09-23 23:19:52 +08:00
724bd6592e 💄 Improvements and optimize UX 2024-09-23 22:43:13 +08:00
2d347e0d41 ♻️ Refactored post item widget 2024-09-23 22:43:02 +08:00
76 changed files with 2877 additions and 2149 deletions

View File

@ -4,3 +4,4 @@ android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false android.nonTransitiveRClass=false
android.nonFinalResIds=false android.nonFinalResIds=false
kotlin.jvm.target.validation.mode = IGNORE

View File

@ -22,9 +22,9 @@
"explore": "Explore", "explore": "Explore",
"posts": "Posts", "posts": "Posts",
"unlink": "Unlink", "unlink": "Unlink",
"feedSearch": "Search Feed", "postSearch": "Search Post",
"feedSearchWithTag": "Searching with tag #@key", "postSearchWithTag": "Searching with tag #@key",
"feedSearchWithCategory": "Searching in category @category", "postSearchWithCategory": "Searching in category @category",
"feedUnreadCount": "@count posts you may missed", "feedUnreadCount": "@count posts you may missed",
"messages": "Messages", "messages": "Messages",
"messagesUnreadCount": "@count messages unread", "messagesUnreadCount": "@count messages unread",
@ -98,6 +98,8 @@
"accountFriendBlocked": "Friend blocklist", "accountFriendBlocked": "Friend blocklist",
"accountFriendListHint": "Swipe left to decline, right to approve", "accountFriendListHint": "Swipe left to decline, right to approve",
"accountFriendRequestSent": "Friend request sent, waiting for processing...", "accountFriendRequestSent": "Friend request sent, waiting for processing...",
"accountBlocked": "Account has been blocked",
"accountUnblocked": "Account has been unblocked",
"accountSuspended": "Account was suspended", "accountSuspended": "Account was suspended",
"accountSuspendedAt": "Account was suspended since @date", "accountSuspendedAt": "Account was suspended since @date",
"aspectRatio": "Aspect Ratio", "aspectRatio": "Aspect Ratio",
@ -453,5 +455,27 @@
"accountDeletionConfirm": "Confirm request account deletion", "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.", "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.", "accountDeletionRequested": "Account deletion requested, check your inbox to confirm the request.",
"slideToConfirm": "Slide to confirm" "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"
} }

View File

@ -32,9 +32,9 @@
"dashboard": "仪表盘", "dashboard": "仪表盘",
"today": "今日", "today": "今日",
"yesterday": "昨日", "yesterday": "昨日",
"feedSearch": "搜索资讯", "postSearch": "搜索帖子",
"feedSearchWithTag": "检索带有 #@key 标签的资讯", "postSearchWithTag": "检索带有 #@key 标签的资讯",
"feedSearchWithCategory": "检索位于分类 @category 的资讯", "postSearchWithCategory": "检索位于分类 @category 的资讯",
"feedUnreadCount": "@count 条你可能错过的帖子", "feedUnreadCount": "@count 条你可能错过的帖子",
"messages": "消息", "messages": "消息",
"messagesUnreadCount": "@count 条未读的消息", "messagesUnreadCount": "@count 条未读的消息",
@ -98,6 +98,8 @@
"accountFriendBlocked": "好友黑名单", "accountFriendBlocked": "好友黑名单",
"accountFriendListHint": "左滑来拒绝,右滑来接受", "accountFriendListHint": "左滑来拒绝,右滑来接受",
"accountFriendRequestSent": "好友请求已发送,等待处理对方中……", "accountFriendRequestSent": "好友请求已发送,等待处理对方中……",
"accountBlocked": "已屏蔽账号",
"accountUnblocked": "已解除屏蔽账号",
"accountSuspended": "帐号被停用", "accountSuspended": "帐号被停用",
"accountSuspendedAt": "该帐号自 @date 起被停用", "accountSuspendedAt": "该帐号自 @date 起被停用",
"aspectRatio": "纵横比", "aspectRatio": "纵横比",
@ -264,7 +266,7 @@
"channelMembersAddHint": "到 @channel", "channelMembersAddHint": "到 @channel",
"channelType": "频道类型", "channelType": "频道类型",
"channelTypeCommon": "普通频道", "channelTypeCommon": "普通频道",
"channelTypeDirect": "私信聊天", "channelTypeDirect": "私信",
"channelAdjust": "调整频道", "channelAdjust": "调整频道",
"channelDetail": "频道详情", "channelDetail": "频道详情",
"channelSettings": "频道设置", "channelSettings": "频道设置",
@ -449,5 +451,27 @@
"accountDeletionConfirm": "确认账号删除请求", "accountDeletionConfirm": "确认账号删除请求",
"accountDeletionConfirmDesc": "你确定要删除账号 @account 吗?你将会在其绑定的主要邮件地址收到一封包含着确认删除账号连接的邮件,在二十四小时内使用该连接即可完成删除账号。注意,本操作不可撤销,并且账号创建或关联的所有数据都将被删除,请三思而后行。", "accountDeletionConfirmDesc": "你确定要删除账号 @account 吗?你将会在其绑定的主要邮件地址收到一封包含着确认删除账号连接的邮件,在二十四小时内使用该连接即可完成删除账号。注意,本操作不可撤销,并且账号创建或关联的所有数据都将被删除,请三思而后行。",
"accountDeletionRequested": "已请求删除账号,检查你的收件箱来确认请求。", "accountDeletionRequested": "已请求删除账号,检查你的收件箱来确认请求。",
"slideToConfirm": "滑动来确认" "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": "全局背景图片将会在所有页面中展示"
} }

View File

@ -227,6 +227,8 @@ PODS:
- TOCropViewController (~> 2.7.4) - TOCropViewController (~> 2.7.4)
- image_picker_ios (0.0.1): - image_picker_ios (0.0.1):
- Flutter - Flutter
- in_app_review (0.2.0):
- Flutter
- livekit_client (2.2.6): - livekit_client (2.2.6):
- Flutter - Flutter
- WebRTC-SDK (= 125.6422.04) - WebRTC-SDK (= 125.6422.04)
@ -318,6 +320,7 @@ DEPENDENCIES:
- gal (from `.symlinks/plugins/gal/darwin`) - gal (from `.symlinks/plugins/gal/darwin`)
- image_cropper (from `.symlinks/plugins/image_cropper/ios`) - image_cropper (from `.symlinks/plugins/image_cropper/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/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`) - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/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`) - 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" :path: ".symlinks/plugins/image_cropper/ios"
image_picker_ios: image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios" :path: ".symlinks/plugins/image_picker_ios/ios"
in_app_review:
:path: ".symlinks/plugins/in_app_review/ios"
livekit_client: livekit_client:
:path: ".symlinks/plugins/livekit_client/ios" :path: ".symlinks/plugins/livekit_client/ios"
media_kit_libs_ios_video: media_kit_libs_ios_video:
@ -482,6 +487,7 @@ SPEC CHECKSUMS:
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
livekit_client: 20e01637431bc108dad451c8a11c1d206e1dd2cd livekit_client: 20e01637431bc108dad451c8a11c1d206e1dd2cd
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1 media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a

View File

@ -1,87 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
<key>CFBundleURLName</key> <key>CFBundleURLName</key>
<string></string> <string></string>
<key>CFBundleTypeRole</key> <key>CFBundleTypeRole</key>
<string>Editor</string> <string>Editor</string>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array> <array>
<string>solink</string> <string>solink</string>
</array> </array>
</dict> </dict>
</array> </array>
<key>FirebaseMessagingAutoInitEnabled</key> <key>FirebaseMessagingAutoInitEnabled</key>
<false/> <false/>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Solian</string> <string>Solian</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>solian</string> <string>solian</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>Allow you take photo/video for your message or post</string> <string>Allow you take photo/video for your message or post</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>Allow you record audio for your message or post</string> <string>Allow you record audio for your message or post</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Allow you add photo to your message or post</string> <string>Allow you add photo to your message or post</string>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>audio</string> <string>audio</string>
<string>fetch</string> <string>fetch</string>
<string>remote-notification</string> <string>remote-notification</string>
<string>voip</string> <string>voip</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>FlutterDeepLinkingEnabled</key> <key>FlutterDeepLinkingEnabled</key>
<true/> <true/>
<key>NSUserActivityTypes</key> <key>NSUserActivityTypes</key>
<array> <array>
<string>INSendMessageIntent</string> <string>INSendMessageIntent</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UIStatusBarHidden</key> <key>CFBundleLocalizations</key>
<false/> <array>
</dict> <string>zh_CN</string>
<string>en</string>
</array>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist> </plist>

View File

@ -1,8 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.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:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@ -10,12 +12,12 @@ import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/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/content/realm.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/providers/theme_switcher.dart'; import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
import 'package:flutter_app_update/flutter_app_update.dart'; import 'package:flutter_app_update/flutter_app_update.dart';
import 'package:version/version.dart'; import 'package:version/version.dart';
@ -42,6 +44,27 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
final Completer _bootCompleter = Completer(); 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) { void _updateNow(String localVersionString, String remoteVersionString) {
context context
.showConfirmDialog( .showConfirmDialog(
@ -175,8 +198,6 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
try { try {
await Future.wait([ await Future.wait([
if (auth.isAuthorized.isTrue)
Get.find<ChannelProvider>().refreshAvailableChannel(),
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
Get.find<RelationshipProvider>().refreshRelativeList(), Get.find<RelationshipProvider>().refreshRelativeList(),
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
@ -226,14 +247,16 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
super.initState(); super.initState();
_runPeriods(); _runPeriods();
_checkForUpdate(); _checkForUpdate();
_bootCompleter.future.then((_) {
_requestRating();
});
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isBusy || _isErrored) { if (_isBusy || _isErrored) {
return GestureDetector( return GestureDetector(
child: Material( child: RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Column( child: Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,

View File

@ -57,13 +57,16 @@ void main() async {
Future<void> _initializeFirebase() async { Future<void> _initializeFirebase() async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
FlutterError.onError = (errorDetails) { if (PlatformInfo.isIOS || PlatformInfo.isAndroid || PlatformInfo.isMacOS) {
FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails); // Initialize firebase crashlytics for the platform that supported
}; FlutterError.onError = (errorDetails) {
PlatformDispatcher.instance.onError = (error, stack) { FirebaseCrashlytics.instance.recordFlutterFatalError(errorDetails);
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); };
return true; PlatformDispatcher.instance.onError = (error, stack) {
}; FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
return true;
};
}
} }
Future<void> _initializeBackgroundNotificationService() async { Future<void> _initializeBackgroundNotificationService() async {

50
lib/models/theme.dart Normal file
View 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
View 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,
};

View File

@ -27,6 +27,10 @@ abstract class PlatformInfo {
static bool get canCacheImage => isAndroid || isIOS || isMacOS; 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 canRecord => (isMobile || isMacOS);
static bool get canPushNotification => isAndroid || isIOS || isMacOS; static bool get canPushNotification => isAndroid || isIOS || isMacOS;
@ -38,4 +42,4 @@ abstract class PlatformInfo {
} catch (_) {} } catch (_) {}
return version; return version;
} }
} }

View File

@ -392,7 +392,7 @@ class ChatCallProvider extends GetxController {
} }
Future gotoScreen(BuildContext context) { Future gotoScreen(BuildContext context) {
return Navigator.of(context, rootNavigator: true).push( return Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const CallScreen()), MaterialPageRoute(builder: (context) => const CallScreen()),
); );
} }

View File

@ -9,25 +9,6 @@ import 'package:uuid/uuid.dart';
class ChannelProvider extends GetxController { class ChannelProvider extends GetxController {
RxBool isLoading = false.obs; 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 { Future<Response> getChannel(String alias, {String realm = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
@ -89,18 +70,22 @@ class ChannelProvider extends GetxController {
return resp; 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(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException(); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = await auth.configureClient('messaging'); 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) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
} }
return resp; return List.from(resp.body.map((x) => Channel.fromJson(x)));
} }
Future<Response> createChannel(String scope, dynamic payload) async { Future<Response> createChannel(String scope, dynamic payload) async {

View File

@ -1,3 +1,6 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:get/get.dart' hide Value; import 'package:get/get.dart' hide Value;
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
@ -182,4 +185,26 @@ class MessagesFetchingProvider extends GetxController {
..orderBy([(t) => OrderingTerm.desc(t.id)])) ..orderBy([(t) => OrderingTerm.desc(t.id)]))
.getSingleOrNull(); .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);
}
} }

View File

@ -26,6 +26,19 @@ class RelationshipProvider extends GetxController {
return _friends.any((x) => x.relatedId == account.id); 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 { Future<Response> listRelation() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = await auth.configureClient('auth'); final client = await auth.configureClient('auth');
@ -38,7 +51,19 @@ class RelationshipProvider extends GetxController {
return client.get('/users/me/relations?status=$status'); 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 AuthProvider auth = Get.find();
final client = await auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.post('/users/me/relations?related=$username', {}); final resp = await client.post('/users/me/relations?related=$username', {});
@ -46,7 +71,7 @@ class RelationshipProvider extends GetxController {
throw RequestException(resp); throw RequestException(resp);
} }
return resp; return Relationship.fromJson(resp.body);
} }
Future<Response> handleRelation( Future<Response> handleRelation(
@ -64,17 +89,17 @@ class RelationshipProvider extends GetxController {
return resp; return resp;
} }
Future<Response> editRelation(Relationship relationship, int status) async { Future<Relationship?> editRelation(int relatedId, int status) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = await auth.configureClient('auth'); final client = await auth.configureClient('auth');
final resp = await client.patch( final resp = await client.put(
'/users/me/relations/${relationship.relatedId}', '/users/me/relations/$relatedId',
{'status': status}, {'status': status},
); );
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw RequestException(resp); throw RequestException(resp);
} }
return resp; return Relationship.fromJson(resp.body);
} }
} }

View File

@ -1,5 +1,8 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/models/theme.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
class ThemeSwitcher extends ChangeNotifier { class ThemeSwitcher extends ChangeNotifier {
@ -13,11 +16,21 @@ class ThemeSwitcher extends ChangeNotifier {
Future<void> restoreTheme() async { Future<void> restoreTheme() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('global_theme_color')) { if (prefs.containsKey('global_theme')) {
final value = prefs.getInt('global_theme_color')!; final value = SolianThemeData.fromJson(
final color = Color(value); jsonDecode(prefs.getString('global_theme')!),
lightThemeData = AppTheme.build(Brightness.light, seedColor: color); );
darkThemeData = AppTheme.build(Brightness.dark, seedColor: color); 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(); notifyListeners();
} }
} }
@ -27,4 +40,25 @@ class ThemeSwitcher extends ChangeNotifier {
darkThemeData = dark; darkThemeData = dark;
notifyListeners(); 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();
}
} }

View File

@ -23,11 +23,13 @@ import 'package:solian/screens/realms.dart';
import 'package:solian/screens/realms/realm_detail.dart'; import 'package:solian/screens/realms/realm_detail.dart';
import 'package:solian/screens/realms/realm_organize.dart'; import 'package:solian/screens/realms/realm_organize.dart';
import 'package:solian/screens/realms/realm_view.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/posts/post_editor.dart';
import 'package:solian/screens/settings.dart'; import 'package:solian/screens/settings.dart';
import 'package:solian/shells/root_shell.dart'; import 'package:solian/shells/root_shell.dart';
import 'package:solian/shells/title_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 { abstract class AppRouter {
static GoRouter instance = GoRouter( static GoRouter instance = GoRouter(
@ -78,13 +80,18 @@ abstract class AppRouter {
builder: (context, state, child) => child, builder: (context, state, child) => child,
routes: [ routes: [
GoRoute( GoRoute(
path: '/feed', path: '/explore',
name: 'feed', name: 'explore',
builder: (context, state) => const FeedScreen(), builder: (context, state) => const ExploreScreen(),
), ),
GoRoute( GoRoute(
path: '/feed/search', path: '/drafts',
name: 'feedSearch', name: 'draftBox',
builder: (context, state) => const DraftBoxScreen(),
),
GoRoute(
path: '/posts/search',
name: 'postSearch',
builder: (context, state) => TitleShell( builder: (context, state) => TitleShell(
state: state, state: state,
child: FeedSearchScreen( child: FeedSearchScreen(
@ -93,11 +100,6 @@ abstract class AppRouter {
), ),
), ),
), ),
GoRoute(
path: '/drafts',
name: 'draftBox',
builder: (context, state) => const DraftBoxScreen(),
),
GoRoute( GoRoute(
path: '/posts/view/:id', path: '/posts/view/:id',
name: 'postDetail', name: 'postDetail',
@ -137,12 +139,15 @@ abstract class AppRouter {
); );
static final ShellRoute _chatRoute = ShellRoute( static final ShellRoute _chatRoute = ShellRoute(
builder: (context, state, child) => child, builder: (context, state, child) =>
AppTheme.isLargeScreen(context) ? ChatListShell(child: child) : child,
routes: [ routes: [
GoRoute( GoRoute(
path: '/chat', path: '/chat',
name: 'chat', name: 'chat',
builder: (context, state) => const ChatScreen(), builder: (context, state) => AppTheme.isLargeScreen(context)
? const EmptyPagePlaceholder()
: const ChatScreen(),
), ),
GoRoute( GoRoute(
path: '/chat/organize', path: '/chat/organize',

View File

@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:package_info_plus/package_info_plus.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:solian/widgets/sized_container.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -13,8 +16,7 @@ class AboutScreen extends StatelessWidget {
const denseButtonStyle = const denseButtonStyle =
ButtonStyle(visualDensity: VisualDensity(vertical: -4)); ButtonStyle(visualDensity: VisualDensity(vertical: -4));
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: Column( child: Column(
@ -52,8 +54,9 @@ class AboutScreen extends StatelessWidget {
CenteredContainer( CenteredContainer(
maxWidth: 280, maxWidth: 280,
child: Wrap( child: Wrap(
spacing: 8, spacing: 4,
runSpacing: 8, runSpacing: 4,
alignment: WrapAlignment.center,
children: [ children: [
TextButton( TextButton(
style: denseButtonStyle, style: denseButtonStyle,
@ -91,6 +94,13 @@ class AboutScreen extends StatelessWidget {
launchUrlString('https://solsynth.dev/terms'); launchUrlString('https://solsynth.dev/terms');
}, },
), ),
TextButton(
style: denseButtonStyle,
child: Text('serviceStatus'.tr),
onPressed: () {
launchUrlString('https://status.solsynth.dev');
},
),
], ],
), ),
), ),
@ -102,6 +112,34 @@ class AboutScreen extends StatelessWidget {
fontSize: 12, 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,
);
}
},
),
], ],
), ),
), ),

View File

@ -7,6 +7,7 @@ import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/widgets/account/account_heading.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:solian/widgets/sized_container.dart';
import 'package:badges/badges.dart' as badges; import 'package:badges/badges.dart' as badges;
@ -49,8 +50,7 @@ class _AccountScreenState extends State<AccountScreen> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: SafeArea( child: SafeArea(
child: Obx(() { child: Obx(() {
if (auth.isAuthorized.isFalse) { if (auth.isAuthorized.isFalse) {

View File

@ -6,6 +6,7 @@ import 'package:solian/models/relations.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/account/relative_list.dart'; import 'package:solian/widgets/account/relative_list.dart';
import 'package:solian/widgets/root_container.dart';
class FriendScreen extends StatefulWidget { class FriendScreen extends StatefulWidget {
const FriendScreen({super.key}); const FriendScreen({super.key});
@ -117,8 +118,7 @@ class _FriendScreenState extends State<FriendScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
centerTitle: false, centerTitle: false,

View File

@ -6,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/root_container.dart';
class NotificationPreferencesScreen extends StatefulWidget { class NotificationPreferencesScreen extends StatefulWidget {
const NotificationPreferencesScreen({super.key}); const NotificationPreferencesScreen({super.key});
@ -74,8 +75,7 @@ class _NotificationPreferencesScreenState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Column( child: Column(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), if (_isBusy) const LinearProgressIndicator().animate().scaleX(),

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
@ -9,10 +7,12 @@ import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart'; import 'package:solian/providers/content/attachment.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/root_container.dart';
class PersonalizeScreen extends StatefulWidget { class PersonalizeScreen extends StatefulWidget {
const PersonalizeScreen({super.key}); const PersonalizeScreen({super.key});
@ -77,36 +77,42 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
XFile file;
final image = await _imagePicker.pickImage(source: ImageSource.gallery); final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return; if (image == null) return;
CroppedFile? croppedFile = await ImageCropper().cropImage( if (PlatformInfo.canCropImage) {
sourcePath: image.path, CroppedFile? croppedFile = await ImageCropper().cropImage(
uiSettings: [ sourcePath: image.path,
AndroidUiSettings( uiSettings: [
toolbarTitle: 'cropImage'.tr, AndroidUiSettings(
toolbarColor: Theme.of(context).colorScheme.primary, toolbarTitle: 'cropImage'.tr,
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary, toolbarColor: Theme.of(context).colorScheme.primary,
aspectRatioPresets: [ toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
if (position == 'avatar') CropAspectRatioPreset.square, aspectRatioPresets: [
if (position == 'banner') _BannerCropAspectRatioPreset(), if (position == 'avatar') CropAspectRatioPreset.square,
], if (position == 'banner') _BannerCropAspectRatioPreset(),
), ],
IOSUiSettings( ),
title: 'cropImage'.tr, IOSUiSettings(
aspectRatioPresets: [ title: 'cropImage'.tr,
if (position == 'avatar') CropAspectRatioPreset.square, aspectRatioPresets: [
if (position == 'banner') _BannerCropAspectRatioPreset(), if (position == 'avatar') CropAspectRatioPreset.square,
], if (position == 'banner') _BannerCropAspectRatioPreset(),
), ],
WebUiSettings( ),
context: context, WebUiSettings(
), context: context,
], ),
); ],
);
if (croppedFile == null) return; if (croppedFile == null) return;
final file = File(croppedFile.path); file = XFile(croppedFile.path);
} else {
file = XFile(image.path);
}
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -181,8 +187,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
const double padding = 32; const double padding = 32;
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: ListView( child: ListView(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), if (_isBusy) const LinearProgressIndicator().animate().scaleX(),

View File

@ -13,6 +13,7 @@ import 'package:solian/models/attachment.dart';
import 'package:solian/models/daily_sign.dart'; import 'package:solian/models/daily_sign.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/models/relations.dart';
import 'package:solian/models/subscription.dart'; import 'package:solian/models/subscription.dart';
import 'package:solian/providers/account_status.dart'; import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
@ -26,6 +27,8 @@ import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:solian/widgets/daily_sign/history_chart.dart'; import 'package:solian/widgets/daily_sign/history_chart.dart';
import 'package:solian/widgets/posts/post_list.dart'; import 'package:solian/widgets/posts/post_list.dart';
import 'package:solian/widgets/posts/post_warped_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'; import 'package:solian/widgets/sized_container.dart';
class AccountProfilePage extends StatefulWidget { class AccountProfilePage extends StatefulWidget {
@ -50,6 +53,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
Account? _userinfo; Account? _userinfo;
Subscription? _subscription; Subscription? _subscription;
Relationship? _relationship;
List<Post> _pinnedPosts = List.empty(); List<Post> _pinnedPosts = List.empty();
List<DailySignRecord> _dailySignRecords = List.empty(); List<DailySignRecord> _dailySignRecords = List.empty();
int _totalUpvote = 0, _totalDownvote = 0; int _totalUpvote = 0, _totalDownvote = 0;
@ -61,6 +65,15 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
setState(() => _isSubscribing = false); 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 { Future<void> _getUserinfo() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -120,6 +133,63 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
} }
} }
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 { int get _userSocialCreditPoints {
return _totalUpvote * 2 - _totalDownvote + _postController.postTotal.value; return _totalUpvote * 2 - _totalDownvote + _postController.postTotal.value;
} }
@ -151,37 +221,20 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
}); });
_getUserinfo().then((_) { _getUserinfo().then((_) {
_getRelationship();
_getSubscription(); _getSubscription();
_getPinnedPosts(); _getPinnedPosts();
_getDailySignRecords(); _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isBusy || _userinfo == null) { if (_isBusy || _userinfo == null) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: DefaultTabController( child: DefaultTabController(
length: 3, length: 3,
child: NestedScrollView( child: NestedScrollView(
@ -221,59 +274,31 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
), ),
), ),
if (_userinfo != null && _subscription == null) if (_userinfo != null && _subscription == null)
OutlinedButton( IconButton(
style: const ButtonStyle( style: const ButtonStyle(
visualDensity: visualDensity:
VisualDensity(horizontal: -4, vertical: -2), VisualDensity(horizontal: -4, vertical: -2),
), ),
onPressed: _isSubscribing onPressed: _isSubscribing ? null : _subscribeToUser,
? null icon: const Icon(Icons.add_circle_outline),
: () async { tooltip: 'subscribe'.tr,
setState(() => _isSubscribing = true);
_subscription =
await Get.find<SubscriptionProvider>()
.subscribeToUser(_userinfo!.id);
setState(() => _isSubscribing = false);
},
child: Text('subscribe'.tr),
) )
else if (_userinfo != null) else if (_userinfo != null)
OutlinedButton( IconButton(
style: const ButtonStyle( style: const ButtonStyle(
visualDensity: visualDensity:
VisualDensity(horizontal: -4, vertical: -2), VisualDensity(horizontal: -4, vertical: -2),
), ),
onPressed: _isSubscribing onPressed:
? null _isSubscribing ? null : _unsubscribeFromUser,
: () async { icon: const Icon(Icons.remove_circle_outline),
setState(() => _isSubscribing = true); tooltip: 'unsubscribe'.tr,
await Get.find<SubscriptionProvider>()
.unsubscribeFromUser(_userinfo!.id);
_subscription = null;
setState(() => _isSubscribing = false);
},
child: Text('unsubscribe'.tr),
), ),
if (_userinfo != null && if (_userinfo != null && _relationship == null)
!_relationshipProvider.hasFriend(_userinfo!))
IconButton( IconButton(
icon: const Icon(Icons.person_add), icon: const Icon(Icons.person_add),
onPressed: _isMakingFriend onPressed: _isMakingFriend ? null : _makeFriend,
? null tooltip: 'friendAdd'.tr,
: () async {
setState(() => _isMakingFriend = true);
try {
await _relationshipProvider
.makeFriend(widget.name);
context.showSnackbar(
'accountFriendRequestSent'.tr,
);
} catch (e) {
context.showErrorDialog(e);
} finally {
setState(() => _isMakingFriend = false);
}
},
) )
else else
const IconButton( const IconButton(
@ -300,8 +325,8 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
children: [ children: [
ListView( ListView(
padding: const EdgeInsets.only(top: 16, bottom: 16),
children: [ children: [
const Gap(16),
CenteredContainer( CenteredContainer(
child: AccountHeadingWidget( child: AccountHeadingWidget(
name: _userinfo!.name, name: _userinfo!.name,
@ -421,9 +446,82 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
), ),
), ),
).marginOnly( ).marginOnly(
right: 24, left: 12, bottom: 8, top: 24), 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),
),
],
),
),
),
],
), ),
), ),
], ],
@ -440,7 +538,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
_buildStatisticsEntry( _StatsWidget(
'totalSocialCreditPoints'.tr, 'totalSocialCreditPoints'.tr,
_userinfo != null _userinfo != null
? _userSocialCreditPoints.toString() ? _userSocialCreditPoints.toString()
@ -453,16 +551,16 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
Obx( Obx(
() => _buildStatisticsEntry( () => _StatsWidget(
'totalPostCount'.tr, 'totalPostCount'.tr,
_postController.postTotal.value.toString(), _postController.postTotal.value.toString(),
), ),
), ),
_buildStatisticsEntry( _StatsWidget(
'totalUpvote'.tr, 'totalUpvote'.tr,
_totalUpvote.toString(), _totalUpvote.toString(),
), ),
_buildStatisticsEntry( _StatsWidget(
'totalDownvote'.tr, 'totalDownvote'.tr,
_totalDownvote.toString(), _totalDownvote.toString(),
), ),
@ -560,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,
),
],
),
);
}
}

View File

@ -7,11 +7,11 @@ import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/auth.dart'; import 'package:solian/models/auth.dart';
import 'package:solian/providers/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/content/realm.dart';
import 'package:solian/providers/relation.dart'; import 'package:solian/providers/relation.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -177,7 +177,6 @@ class _SignInScreenState extends State<SignInScreen> {
await auth.refreshAuthorizeStatus(); await auth.refreshAuthorizeStatus();
await auth.refreshUserProfile(); await auth.refreshUserProfile();
Get.find<ChannelProvider>().refreshAvailableChannel();
Get.find<RealmProvider>().refreshAvailableRealms(); Get.find<RealmProvider>().refreshAvailableRealms();
Get.find<RelationshipProvider>().refreshRelativeList(); Get.find<RelationshipProvider>().refreshRelativeList();
Get.find<WebSocketProvider>().registerPushNotifications(); Get.find<WebSocketProvider>().registerPushNotifications();
@ -218,8 +217,7 @@ class _SignInScreenState extends State<SignInScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: CenteredContainer( child: CenteredContainer(
maxWidth: 360, maxWidth: 360,
child: PageTransitionSwitcher( child: PageTransitionSwitcher(

View File

@ -3,6 +3,7 @@ import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -65,8 +66,7 @@ class _SignUpScreenState extends State<SignUpScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: CenteredContainer( child: CenteredContainer(
maxWidth: 360, maxWidth: 360,
child: ListView( child: ListView(

View File

@ -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_controls.dart';
import 'package:solian/widgets/chat/call/call_participant.dart'; import 'package:solian/widgets/chat/call/call_participant.dart';
import 'package:livekit_client/livekit_client.dart' as livekit; import 'package:livekit_client/livekit_client.dart' as livekit;
import 'package:solian/widgets/root_container.dart';
class CallScreen extends StatefulWidget { class CallScreen extends StatefulWidget {
final bool hideAppBar; final bool hideAppBar;
@ -197,8 +198,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ChatCallProvider ctrl = Get.find(); final ChatCallProvider ctrl = Get.find();
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: widget.hideAppBar appBar: widget.hideAppBar
? null ? null

View File

@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/controllers/chat_events_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/call.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_message_input.dart';
import 'package:solian/widgets/chat/chat_typing_indicator.dart'; import 'package:solian/widgets/chat/chat_typing_indicator.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/root_container.dart';
class ChannelChatScreen extends StatefulWidget { class ChannelChatScreen extends StatefulWidget {
final String alias; final String alias;
@ -179,6 +181,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
} }
} }
late SharedPreferences _prefs;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -189,10 +193,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
_chatController = ChatEventController(); _chatController = ChatEventController();
_chatController.initialize(); _chatController.initialize();
_getOngoingCall(); SharedPreferences.getInstance().then((inst) {
_getChannel().then((_) { _prefs = inst;
_chatController.getInitialEvents(_channel!, widget.realm); _getOngoingCall();
_listenMessages(); _getChannel().then((_) {
_chatController.getInitialEvents(_channel!, widget.realm);
_listenMessages();
});
}); });
} }
@ -201,151 +208,159 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
String title = _channel?.name ?? 'loading'.tr; String title = _channel?.name ?? 'loading'.tr;
String? placeholder; String? placeholder;
if (_channel?.type == 1) { final otherside =
final otherside = _channel?.members!.where((e) => e.account.id != _accountId).firstOrNull;
_channel!.members!.where((e) => e.account.id != _accountId).first;
if (_channel?.type == 1 && otherside != null) {
title = otherside.account.nick; title = otherside.account.nick;
placeholder = 'messageInputPlaceholder'.trParams( placeholder = 'messageInputPlaceholder'.trParams(
{'channel': '@${otherside.account.name}'}, {'channel': '@${otherside.account.name}'},
); );
} }
return Scaffold( return RootContainer(
appBar: AppBar( child: Scaffold(
leading: AppBarLeadingButton.adaptive(context), appBar: AppBar(
title: AppBarTitle(title), leading: AppBarLeadingButton.adaptive(context),
centerTitle: false, title: AppBarTitle(title),
titleSpacing: AppTheme.titleSpacing(context), centerTitle: false,
toolbarHeight: AppTheme.toolbarHeight(context), titleSpacing: AppTheme.titleSpacing(context),
actions: [ toolbarHeight: AppTheme.toolbarHeight(context),
const BackgroundStateWidget(), actions: [
Builder(builder: (context) { const BackgroundStateWidget(),
if (_isBusy || _channel == null) return const SizedBox.shrink(); Builder(builder: (context) {
if (_isBusy || _channel == null) return const SizedBox.shrink();
return ChatCallButton( return ChatCallButton(
realm: _channel!.realm, realm: _channel!.realm,
channel: _channel!, channel: _channel!,
ongoingCall: _ongoingCall, ongoingCall: _ongoingCall,
); );
}), }),
IconButton( IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
onPressed: () { onPressed: () {
if (_channel == null) return; if (_channel == null) return;
AppRouter.instance AppRouter.instance
.pushNamed( .pushNamed(
'channelDetail', 'channelDetail',
pathParameters: {'alias': widget.alias}, pathParameters: {'alias': widget.alias},
queryParameters: {'realm': widget.realm}, queryParameters: {'realm': widget.realm},
extra: ChannelDetailArguments( extra: ChannelDetailArguments(
profile: _channelProfile!, profile: _channelProfile!,
channel: _channel!, 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);
},
),
), ),
ClipRect( )
child: BackdropFilter( .then((value) {
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), if (value == false) AppRouter.instance.pop();
child: SafeArea( if (value != null) {
child: Column( final resp =
children: [ Channel.fromJson(value as Map<String, dynamic>);
ChatTypingIndicator(users: _typingUsers), _getChannel(alias: resp.alias);
ChatMessageInput( }
edit: _messageToEditing, });
reply: _messageToReplying, },
realm: widget.realm, ),
placeholder: placeholder, SizedBox(
channel: _channel!, width: AppTheme.isLargeScreen(context) ? 8 : 16,
onSent: (Event item) { ),
setState(() { ],
_chatController.addPendingEvent(item); ),
}); body: Builder(builder: (context) {
}, if (_isBusy || _channel == null) {
onReset: () { return const Center(
setState(() { child: CircularProgressIndicator(),
_messageToReplying = null; );
_messageToEditing = null; }
});
}, 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(() {
Obx(() { final ChatCallProvider call = Get.find();
final ChatCallProvider call = Get.find(); if (call.isMounted.value &&
if (call.isMounted.value && AppTheme.isLargeScreen(context)) { AppTheme.isUltraLargeScreen(context)) {
return const Expanded( return const Expanded(
child: Row(children: [ child: Row(children: [
VerticalDivider(width: 0.3, thickness: 0.3), VerticalDivider(width: 0.3, thickness: 0.3),
Expanded( Expanded(
child: CallScreen( child: CallScreen(
hideAppBar: true, hideAppBar: true,
isExpandable: true, isExpandable: true,
),
), ),
), ]),
]), );
); }
} return const SizedBox.shrink();
return const SizedBox.shrink(); }),
}), ],
], );
); }),
}), ),
); );
} }

View File

@ -9,6 +9,7 @@ import 'package:solian/providers/content/channel.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class ChannelOrganizeArguments { class ChannelOrganizeArguments {
@ -114,8 +115,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
), ),
]; ];
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: AppBarTitle('channelOrganizing'.tr), title: AppBarTitle('channelOrganizing'.tr),

View File

@ -1,145 +1,326 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.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/router.dart';
import 'package:solian/screens/account/notification.dart'; import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.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/account/signin_required_overlay.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/channel/channel_list.dart'; import 'package:solian/widgets/channel/channel_list.dart';
import 'package:solian/widgets/chat/call/chat_call_indicator.dart'; import 'package:solian/widgets/chat/call/chat_call_indicator.dart';
import 'package:solian/widgets/current_state_action.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}); const ChatScreen({super.key});
@override @override
State<ChatScreen> createState() => _ChatScreenState(); Widget build(BuildContext context) {
return const RootContainer(
child: ChatList(),
);
}
} }
class _ChatScreenState extends State<ChatScreen> { class ChatListShell extends StatelessWidget {
late final ChannelProvider _channels; 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 @override
void initState() { void initState() {
super.initState(); super.initState();
try { _loadLastMessages().then((_) {
_channels = Get.find(); _loadAllChannels();
_channels.refreshAvailableChannel(); });
} catch (e) {
context.showErrorDialog(e);
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final RealmProvider realms = Get.find();
return Material( return Obx(
color: Theme.of(context).colorScheme.surface, () => DefaultTabController(
child: Scaffold( length: 2 + realms.availableRealms.length,
appBar: AppBar( child: RootContainer(
leading: AppBarLeadingButton.adaptive(context), child: Scaffold(
title: AppBarTitle('chat'.tr), appBar: AppBar(
centerTitle: true, leading: Obx(() {
toolbarHeight: AppTheme.toolbarHeight(context), final adaptive = AppBarLeadingButton.adaptive(context);
actions: [ if (adaptive != null) return adaptive;
const BackgroundStateWidget(), if (_channels.isLoading.value) {
const NotificationButton(), return const CircularProgressIndicator(
PopupMenuButton( strokeWidth: 3,
icon: const Icon(Icons.add_circle), ).paddingAll(18);
itemBuilder: (BuildContext context) => [ }
PopupMenuItem( return const SizedBox.shrink();
child: ListTile( }),
title: Text('channelOrganizeCommon'.tr), title: AppBarTitle('chat'.tr),
leading: const Icon(Icons.tag), centerTitle: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8), toolbarHeight: AppTheme.toolbarHeight(context),
), actions: [
onTap: () { const BackgroundStateWidget(),
AppRouter.instance.pushNamed('channelOrganizing').then( const NotificationButton(),
(value) { PopupMenuButton(
if (value != null) { icon: const Icon(Icons.add_circle),
_channels.refreshAvailableChannel(); 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), PopupMenuItem(
), child: ListTile(
onTap: () { title: Text('channelOrganizeDirect'.tr),
final ChannelProvider channels = Get.find(); leading: const FaIcon(
channels FontAwesomeIcons.userGroup,
.createDirectChannel(context, 'global') size: 16,
.then((resp) { ),
if (resp != null) { contentPadding:
_channels.refreshAvailableChannel(); const EdgeInsets.symmetric(horizontal: 8),
} ),
}).catchError((e) { onTap: () {
context.showErrorDialog(e); 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,
), ),
], ],
), bottom: TabBar(
SizedBox( isScrollable: true,
width: AppTheme.isLargeScreen(context) ? 8 : 16, dividerHeight: 0.3,
), tabAlignment: TabAlignment.startOffset,
], tabs: [
), Tab(
body: Obx(() { child: Row(
if (auth.isAuthorized.isFalse) { mainAxisSize: MainAxisSize.min,
return SigninRequiredOverlay( children: [
onDone: () => _channels.refreshAvailableChannel(), CircleAvatar(
); radius: 14,
} backgroundColor:
Theme.of(context).colorScheme.primary,
final selfId = auth.userProfile.value!['id']; child: const Icon(
Icons.forum,
return Column( size: 16,
children: [ color: Colors.white,
Obx(() { ),
if (_channels.isLoading.isFalse) { ),
return const SizedBox.shrink(); const Gap(8),
} else { Text('all'.tr),
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,
),
), ),
), ),
), 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),
),
),
),
],
),
),
],
);
}),
),
),
), ),
); );
} }

View File

@ -354,7 +354,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
IconButton( IconButton(
icon: const Icon(Icons.arrow_forward), icon: const Icon(Icons.arrow_forward),
onPressed: () { onPressed: () {
AppRouter.instance.goNamed('feed'); AppRouter.instance.goNamed('explore');
}, },
), ),
], ],

View File

@ -10,20 +10,21 @@ import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart'; import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/account/signin_required_overlay.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/current_state_action.dart';
import 'package:solian/widgets/app_bar_leading.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_shuffle_swiper.dart';
import 'package:solian/widgets/posts/post_warped_list.dart'; import 'package:solian/widgets/posts/post_warped_list.dart';
import 'package:solian/widgets/root_container.dart';
class FeedScreen extends StatefulWidget { class ExploreScreen extends StatefulWidget {
const FeedScreen({super.key}); const ExploreScreen({super.key});
@override @override
State<FeedScreen> createState() => _FeedScreenState(); State<ExploreScreen> createState() => _ExploreScreenState();
} }
class _FeedScreenState extends State<FeedScreen> class _ExploreScreenState extends State<ExploreScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late final PostListController _postController; late final PostListController _postController;
late final TabController _tabController; late final TabController _tabController;
@ -55,10 +56,8 @@ class _FeedScreenState extends State<FeedScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final NavigationStateProvider navState = Get.find();
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add), child: const Icon(Icons.add),
@ -82,8 +81,14 @@ class _FeedScreenState extends State<FeedScreen>
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [ return [
SliverAppBar( SliverAppBar(
title: AppBarTitle('feed'.tr), flexibleSpace: SizedBox(
centerTitle: false, height: 48,
child: const Row(
children: [
RealmSwitcher(),
],
).paddingSymmetric(horizontal: 8),
).paddingOnly(top: MediaQuery.of(context).padding.top),
floating: true, floating: true,
toolbarHeight: AppTheme.toolbarHeight(context), toolbarHeight: AppTheme.toolbarHeight(context),
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
@ -96,10 +101,39 @@ class _FeedScreenState extends State<FeedScreen>
], ],
bottom: TabBar( bottom: TabBar(
controller: _tabController, controller: _tabController,
dividerHeight: 0.3,
tabAlignment: TabAlignment.fill,
tabs: [ tabs: [
Tab(text: 'postListNews'.tr), Tab(
Tab(text: 'postListFriends'.tr), child: Row(
Tab(text: 'postListShuffle'.tr), 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( return Column(
children: [ 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( Expanded(
child: TabBarView( child: TabBarView(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),

View File

@ -9,6 +9,7 @@ import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/posts/post_action.dart'; import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/post_owned_list.dart'; import 'package:solian/widgets/posts/post_owned_list.dart';
import 'package:solian/widgets/root_container.dart';
class DraftBoxScreen extends StatefulWidget { class DraftBoxScreen extends StatefulWidget {
const DraftBoxScreen({super.key}); const DraftBoxScreen({super.key});
@ -54,8 +55,7 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),

View File

@ -63,13 +63,13 @@ class _FeedSearchScreenState extends State<FeedSearchScreen> {
ListTile( ListTile(
leading: const Icon(Icons.label), leading: const Icon(Icons.label),
tileColor: Theme.of(context).colorScheme.surfaceContainer, tileColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text('feedSearchWithTag'.trParams({'key': widget.tag!})), title: Text('postSearchWithTag'.trParams({'key': widget.tag!})),
), ),
if (widget.category != null) if (widget.category != null)
ListTile( ListTile(
leading: const Icon(Icons.category), leading: const Icon(Icons.category),
tileColor: Theme.of(context).colorScheme.surfaceContainer, tileColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text('feedSearchWithCategory' title: Text('postSearchWithCategory'
.trParams({'key': widget.category!})), .trParams({'key': widget.category!})),
), ),
Expanded( Expanded(

View File

@ -6,6 +6,7 @@ import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/last_read.dart'; import 'package:solian/providers/last_read.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/posts/post_replies.dart'; import 'package:solian/widgets/posts/post_replies.dart';
import 'package:solian/widgets/root_container.dart';
class PostDetailScreen extends StatefulWidget { class PostDetailScreen extends StatefulWidget {
final String id; final String id;
@ -47,8 +48,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: FutureBuilder( child: FutureBuilder(
future: getDetail(), future: getDetail(),
builder: (context, snapshot) { builder: (context, snapshot) {

View File

@ -19,6 +19,7 @@ import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/markdown_text_content.dart'; import 'package:solian/widgets/markdown_text_content.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
import 'package:badges/badges.dart' as badges; import 'package:badges/badges.dart' as badges;
import 'package:solian/widgets/root_container.dart';
class PostPublishArguments { class PostPublishArguments {
final Post? edit; final Post? edit;
@ -151,8 +152,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
) )
]; ];
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),
@ -376,6 +376,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
Expanded( Expanded(
child: SingleChildScrollView( child: SingleChildScrollView(
child: MarkdownTextContent( child: MarkdownTextContent(
isAutoWarp: _editorController.mode.value == 0,
content: _editorController.contentController.text, content: _editorController.contentController.text,
parentId: 'post-editor-preview', parentId: 'post-editor-preview',
).paddingOnly(top: 12, right: 16), ).paddingOnly(top: 12, right: 16),

View File

@ -15,6 +15,7 @@ import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/auto_cache_image.dart'; import 'package:solian/widgets/auto_cache_image.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
class RealmListScreen extends StatefulWidget { class RealmListScreen extends StatefulWidget {
@ -58,8 +59,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),

View File

@ -7,6 +7,7 @@ import 'package:solian/router.dart';
import 'package:solian/screens/realms/realm_organize.dart'; import 'package:solian/screens/realms/realm_organize.dart';
import 'package:solian/widgets/realms/realm_deletion.dart'; import 'package:solian/widgets/realms/realm_deletion.dart';
import 'package:solian/widgets/realms/realm_member.dart'; import 'package:solian/widgets/realms/realm_member.dart';
import 'package:solian/widgets/root_container.dart';
class RealmDetailScreen extends StatefulWidget { class RealmDetailScreen extends StatefulWidget {
final String alias; final String alias;
@ -86,61 +87,63 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
), ),
]; ];
return Column( return RootContainer(
children: [ child: Column(
Padding( children: [
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), Padding(
child: Row( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [ child: Row(
const CircleAvatar( children: [
radius: 28, const CircleAvatar(
backgroundColor: Colors.teal, radius: 28,
child: Icon(Icons.group, color: Colors.white), 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 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),
const Divider(thickness: 0.3), Expanded(
Expanded( child: ListView(
child: ListView( children: [
children: [ ListTile(
ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: const Icon(Icons.supervisor_account),
leading: const Icon(Icons.supervisor_account), trailing: const Icon(Icons.chevron_right),
trailing: const Icon(Icons.chevron_right), title: Text('realmMembers'.tr),
title: Text('realmMembers'.tr), onTap: () => showMemberList(),
onTap: () => showMemberList(), ),
), ...(_isOwned ? ownerActions : List.empty()),
...(_isOwned ? ownerActions : List.empty()), const Divider(thickness: 0.3),
const Divider(thickness: 0.3), ListTile(
ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: _isOwned
leading: _isOwned ? const Icon(Icons.delete)
? const Icon(Icons.delete) : const Icon(Icons.exit_to_app),
: const Icon(Icons.exit_to_app), title: Text(_isOwned ? 'delete'.tr : 'leave'.tr),
title: Text(_isOwned ? 'delete'.tr : 'leave'.tr), onTap: () => promptLeaveChannel(),
onTap: () => promptLeaveChannel(), ),
), ],
], ),
), ),
), ],
], ),
); );
} }
} }

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.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/exts.dart';
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart'; import 'package:solian/providers/content/attachment.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class RealmOrganizeArguments { class RealmOrganizeArguments {
@ -84,36 +84,42 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
XFile file;
final image = await _imagePicker.pickImage(source: ImageSource.gallery); final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return; if (image == null) return;
CroppedFile? croppedFile = await ImageCropper().cropImage( if (PlatformInfo.canCropImage) {
sourcePath: image.path, CroppedFile? croppedFile = await ImageCropper().cropImage(
uiSettings: [ sourcePath: image.path,
AndroidUiSettings( uiSettings: [
toolbarTitle: 'cropImage'.tr, AndroidUiSettings(
toolbarColor: Theme.of(context).colorScheme.primary, toolbarTitle: 'cropImage'.tr,
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary, toolbarColor: Theme.of(context).colorScheme.primary,
aspectRatioPresets: [ toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
if (position == 'avatar') CropAspectRatioPreset.square, aspectRatioPresets: [
if (position == 'banner') _BannerCropAspectRatioPreset(), if (position == 'avatar') CropAspectRatioPreset.square,
], if (position == 'banner') _BannerCropAspectRatioPreset(),
), ],
IOSUiSettings( ),
title: 'cropImage'.tr, IOSUiSettings(
aspectRatioPresets: [ title: 'cropImage'.tr,
if (position == 'avatar') CropAspectRatioPreset.square, aspectRatioPresets: [
if (position == 'banner') _BannerCropAspectRatioPreset(), if (position == 'avatar') CropAspectRatioPreset.square,
], if (position == 'banner') _BannerCropAspectRatioPreset(),
), ],
WebUiSettings( ),
context: context, WebUiSettings(
), context: context,
], ),
); ],
);
if (croppedFile == null) return; if (croppedFile == null) return;
final file = File(croppedFile.path); file = XFile(croppedFile.path);
} else {
file = XFile(image.path);
}
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -184,8 +190,7 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context), leading: AppBarLeadingButton.adaptive(context),

View File

@ -16,6 +16,7 @@ import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/channel/channel_list.dart'; import 'package:solian/widgets/channel/channel_list.dart';
import 'package:solian/widgets/posts/post_list.dart'; import 'package:solian/widgets/posts/post_list.dart';
import 'package:solian/widgets/root_container.dart';
class RealmViewScreen extends StatefulWidget { class RealmViewScreen extends StatefulWidget {
final String alias; final String alias;
@ -68,12 +69,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
_channels.addAll( _channels.addAll(
resp.body.map((e) => Channel.fromJson(e)).toList().cast<Channel>(), resp.body.map((e) => Channel.fromJson(e)).toList().cast<Channel>(),
); );
_channels.addAll( _channels.addAll(availableResp);
availableResp.body
.map((e) => Channel.fromJson(e))
.toList()
.cast<Channel>(),
);
_channels.retainWhere((x) => channelIdx.add(x.id)); _channels.retainWhere((x) => channelIdx.add(x.id));
}); });
@ -91,8 +87,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: DefaultTabController( child: DefaultTabController(
length: 2, length: 2,
child: NestedScrollView( child: NestedScrollView(
@ -260,7 +255,6 @@ class RealmChannelListWidget extends StatelessWidget {
child: ChannelListWidget( child: ChannelListWidget(
channels: channels, channels: channels,
selfId: auth.userProfile.value!['id'], selfId: auth.userProfile.value!['id'],
noCategory: true,
), ),
) )
], ],

View File

@ -1,16 +1,25 @@
import 'dart:convert';
import 'dart:io';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.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:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/theme.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/database/database.dart'; import 'package:solian/providers/database/database.dart';
import 'package:solian/providers/theme_switcher.dart'; import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/reports/abuse_report.dart'; import 'package:solian/widgets/reports/abuse_report.dart';
import 'package:solian/widgets/root_container.dart';
class SettingScreen extends StatefulWidget { class SettingScreen extends StatefulWidget {
const SettingScreen({super.key}); const SettingScreen({super.key});
@ -21,6 +30,7 @@ class SettingScreen extends StatefulWidget {
class _SettingScreenState extends State<SettingScreen> { class _SettingScreenState extends State<SettingScreen> {
SharedPreferences? _prefs; SharedPreferences? _prefs;
String _docBasepath = '/';
Widget _buildCaptionHeader(String title) { Widget _buildCaptionHeader(String title) {
return Container( return Container(
@ -31,39 +41,38 @@ class _SettingScreenState extends State<SettingScreen> {
); );
} }
Widget _buildThemeColorButton(String label, Color color) { static final List<SolianThemeData> _presentTheme = [
return IconButton( SolianThemeData(
icon: Icon(Icons.circle, color: color), id: 'themeColorRed',
tooltip: label, seedColor: const Color.fromRGBO(154, 98, 91, 1),
onPressed: () { ),
context.read<ThemeSwitcher>().setTheme( SolianThemeData(
AppTheme.build( id: 'themeColorBlue',
Brightness.light, seedColor: const Color.fromRGBO(103, 96, 193, 1),
seedColor: color, ),
), SolianThemeData(
AppTheme.build( id: 'themeColorMiku',
Brightness.dark, seedColor: const Color.fromRGBO(56, 120, 126, 1),
seedColor: color, ),
), SolianThemeData(
); id: 'themeColorKagamine',
_prefs?.setInt('global_theme_color', color.value); seedColor: const Color.fromRGBO(244, 183, 63, 1),
context.clearSnackbar(); ),
context.showSnackbar('themeColorApplied'.tr); SolianThemeData(
}, id: 'themeColorLuka',
); seedColor: const Color.fromRGBO(243, 174, 218, 1),
} ),
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)),
]; ];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
getApplicationDocumentsDirectory().then((dir) {
_docBasepath = dir.path;
if (mounted) {
setState(() {});
}
});
SharedPreferences.getInstance().then((inst) { SharedPreferences.getInstance().then((inst) {
_prefs = inst; _prefs = inst;
if (mounted) { if (mounted) {
@ -74,20 +83,101 @@ class _SettingScreenState extends State<SettingScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: ListView( child: ListView(
children: [ children: [
_buildCaptionHeader('themeColor'.tr), _buildCaptionHeader('theme'.tr),
SizedBox( ListTile(
height: 56, leading: const Icon(Icons.palette),
child: ListView( contentPadding: const EdgeInsets.symmetric(horizontal: 22),
scrollDirection: Axis.horizontal, title: Text('globalTheme'.tr),
children: _presentTheme trailing: DropdownButtonHideUnderline(
.map((x) => _buildThemeColorButton(x.$1, x.$2)) child: DropdownButton2<SolianThemeData>(
.toList(), isExpanded: true,
).paddingSymmetric(horizontal: 12, vertical: 8), 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), _buildCaptionHeader('notification'.tr),
Tooltip( Tooltip(
message: 'settingsNotificationBgServiceDesc'.tr, message: 'settingsNotificationBgServiceDesc'.tr,
@ -180,6 +270,21 @@ class _SettingScreenState extends State<SettingScreen> {
], ],
); );
}), }),
_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), _buildCaptionHeader('more'.tr),
ListTile( ListTile(
leading: const Icon(Icons.delete_sweep), leading: const Icon(Icons.delete_sweep),
@ -205,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( ListTile(
leading: const Icon(Icons.info_outline), leading: const Icon(Icons.info_outline),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),

View File

@ -2,7 +2,9 @@ import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:solian/theme.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>(); 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( return Scaffold(
key: rootScaffoldKey, key: rootScaffoldKey,
drawer: AppTheme.isLargeScreen(context) bottomNavigationBar: showBottomNavigation
? null ? AppNavigationBottom(
: AppNavigationDrawer(routeName: routeName), initialIndex: destNames.indexOf(routeName ?? 'page'),
)
: null,
body: AppTheme.isLargeScreen(context) body: AppTheme.isLargeScreen(context)
? Row( ? Row(
children: [ children: [
if (showNavigation) AppNavigationDrawer(routeName: routeName), if (showRailNavigation) const AppNavigationRail(),
if (showNavigation) if (showRailNavigation)
const VerticalDivider(thickness: 0.3, width: 1), const VerticalDivider(
width: 0.3,
thickness: 0.3,
),
Expanded(child: child), Expanded(child: child),
], ],
) )

View File

@ -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,
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_title.dart'; import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/app_bar_leading.dart'; import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/root_container.dart';
class TitleShell extends StatelessWidget { class TitleShell extends StatelessWidget {
final bool showAppBar; final bool showAppBar;
@ -26,24 +27,26 @@ class TitleShell extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(state != null || title != null); assert(state != null || title != null);
return Scaffold( return RootContainer(
appBar: showAppBar child: Scaffold(
? AppBar( appBar: showAppBar
leading: AppBarLeadingButton.adaptive(context), ? AppBar(
title: AppBarTitle( leading: AppBarLeadingButton.adaptive(context),
title ?? (state!.topRoute?.name?.tr ?? 'page'.tr), 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,
), ),
], centerTitle: isCenteredTitle,
) toolbarHeight: AppTheme.toolbarHeight(context),
: null, actions: [
body: child, const BackgroundStateWidget(),
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
),
],
)
: null,
body: child,
),
); );
} }
} }

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:solian/models/theme.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
abstract class AppTheme { abstract class AppTheme {
@ -6,7 +7,10 @@ abstract class AppTheme {
MediaQuery.of(context).size.width > 640; MediaQuery.of(context).size.width > 640;
static bool isExtraLargeScreen(BuildContext context) => 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) => static bool isSpecializedMacOS(BuildContext context) =>
PlatformInfo.isMacOS && !AppTheme.isLargeScreen(context); PlatformInfo.isMacOS && !AppTheme.isLargeScreen(context);
@ -35,6 +39,7 @@ abstract class AppTheme {
brightness: brightness, brightness: brightness,
seedColor: seedColor ?? const Color.fromRGBO(154, 98, 91, 1), seedColor: seedColor ?? const Color.fromRGBO(154, 98, 91, 1),
), ),
scaffoldBackgroundColor: Colors.transparent,
snackBarTheme: const SnackBarThemeData( snackBarTheme: const SnackBarThemeData(
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
@ -52,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(),
),
);
}
} }

View File

@ -7,6 +7,7 @@ class AccountAvatar extends StatelessWidget {
final Color? bgColor; final Color? bgColor;
final Color? feColor; final Color? feColor;
final double? radius; final double? radius;
final Widget? fallbackWidget;
const AccountAvatar({ const AccountAvatar({
super.key, super.key,
@ -14,6 +15,7 @@ class AccountAvatar extends StatelessWidget {
this.bgColor, this.bgColor,
this.feColor, this.feColor,
this.radius, this.radius,
this.fallbackWidget,
}); });
@override @override
@ -35,11 +37,12 @@ class AccountAvatar extends StatelessWidget {
backgroundColor: bgColor, backgroundColor: bgColor,
backgroundImage: !isEmpty ? AutoCacheImage.provider(url) : null, backgroundImage: !isEmpty ? AutoCacheImage.provider(url) : null,
child: isEmpty child: isEmpty
? Icon( ? (fallbackWidget ??
Icons.account_circle, Icon(
size: radius != null ? radius! * 1.2 : 24, Icons.account_circle,
color: feColor, size: radius != null ? radius! * 1.2 : 24,
) color: feColor,
))
: null, : null,
); );
} }

View File

@ -23,6 +23,7 @@ class AccountHeadingWidget extends StatelessWidget {
final AccountProfile? profile; final AccountProfile? profile;
final List<AccountBadge>? badges; final List<AccountBadge>? badges;
final List<Widget>? extraWidgets; final List<Widget>? extraWidgets;
final List<Widget>? appendWidgets;
final Future<Response>? status; final Future<Response>? status;
final Function? onEditStatus; final Function? onEditStatus;
@ -39,6 +40,7 @@ class AccountHeadingWidget extends StatelessWidget {
this.profile, this.profile,
this.status, this.status,
this.extraWidgets, this.extraWidgets,
this.appendWidgets,
this.onEditStatus, this.onEditStatus,
}); });
@ -257,6 +259,7 @@ class AccountHeadingWidget extends StatelessWidget {
), ),
), ),
).paddingSymmetric(horizontal: 16), ).paddingSymmetric(horizontal: 16),
...?appendWidgets?.map((x) => x.paddingSymmetric(horizontal: 16)),
], ],
), ),
); );

View File

@ -106,10 +106,14 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
extraWidgets: [ extraWidgets: [
Card( Card(
child: ListTile( child: ListTile(
leading: const Icon(
Icons.contact_page_outlined,
),
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)), borderRadius: BorderRadius.all(Radius.circular(8)),
), ),
title: Text('visitProfilePage'.tr), title: Text('visitProfilePage'.tr),
subtitle: Text('learnMoreAboutPerson'.tr),
visualDensity: visualDensity:
const VisualDensity(horizontal: -4, vertical: -2), const VisualDensity(horizontal: -4, vertical: -2),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),

View File

@ -28,42 +28,46 @@ class SilverRelativeList extends StatelessWidget {
showModalBottomSheet( showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Theme backgroundColor: Theme.of(context).colorScheme.surface,
.of(context)
.colorScheme
.surface,
context: context, context: context,
builder: (context) => builder: (context) => AccountProfilePopup(
AccountProfilePopup( name: element.related.name,
name: element.related.name, ),
),
); );
}, },
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if(element.status != 1 && element.status != 3) if (element.status != 1 && element.status != 3)
IconButton( IconButton(
icon: const Icon(Icons.check), icon: const Icon(Icons.check),
onPressed: () { onPressed: () {
final RelationshipProvider provider = Get.find(); final RelationshipProvider provider = Get.find();
if (element.status == 0) { if (element.status == 0) {
provider.handleRelation(element, true).then((_) => onUpdate()); provider
.handleRelation(element, true)
.then((_) => onUpdate());
} else { } 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( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
onPressed: () { onPressed: () {
final RelationshipProvider provider = Get.find(); final RelationshipProvider provider = Get.find();
if (element.status == 0) { if (element.status == 0) {
provider.handleRelation(element, false).then((_) => onUpdate()); provider
.handleRelation(element, false)
.then((_) => onUpdate());
} else { } else {
provider.editRelation(element, 2).then((_) => onUpdate()); provider
.editRelation(element.relatedId, 2)
.then((_) => onUpdate());
} }
}, },
), ),

View File

@ -396,7 +396,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
), ),
if (!element.isCompleted && if (!element.isCompleted &&
element.error == null && element.error == null &&
canBeCrop) canBeCrop &&
PlatformInfo.canCropImage)
Obx( Obx(
() => IconButton( () => IconButton(
color: Colors.teal, color: Colors.teal,
@ -744,8 +745,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
return IgnorePointer( return IgnorePointer(
ignoring: _uploadController.isUploading.value, ignoring: _uploadController.isUploading.value,
child: Container( child: Container(
height: 64,
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
top: BorderSide( top: BorderSide(
@ -754,67 +755,72 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
), ),
), ),
), ),
child: SingleChildScrollView( child: Wrap(
scrollDirection: Axis.horizontal, spacing: 8,
child: Wrap( runSpacing: 8,
spacing: 8, alignment: WrapAlignment.center,
runSpacing: 0, runAlignment: WrapAlignment.center,
alignment: WrapAlignment.center, children: [
runAlignment: WrapAlignment.center, if ((PlatformInfo.isDesktop ||
children: [ PlatformInfo.isIOS ||
if ((PlatformInfo.isDesktop || PlatformInfo.isWeb) &&
PlatformInfo.isIOS || !widget.imageOnly)
PlatformInfo.isWeb) && IconButton(
!widget.imageOnly) icon: const Icon(Icons.paste),
ElevatedButton.icon( tooltip: 'attachmentAddClipboard'.tr,
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),
style: const ButtonStyle(visualDensity: density), style: const ButtonStyle(visualDensity: density),
onPressed: () => _pickPhotoToUpload(), color: Theme.of(context).colorScheme.primary,
onPressed: () => _pasteFileToUpload(),
), ),
if (!widget.imageOnly) IconButton(
ElevatedButton.icon( icon: const Icon(Icons.add_photo_alternate),
icon: const Icon(Icons.add_road), tooltip: 'attachmentAddGalleryPhoto'.tr,
label: Text('attachmentAddGalleryVideo'.tr), style: const ButtonStyle(visualDensity: density),
style: const ButtonStyle(visualDensity: density), color: Theme.of(context).colorScheme.primary,
onPressed: () => _pickVideoToUpload(), onPressed: () => _pickPhotoToUpload(),
), ),
ElevatedButton.icon( if (!widget.imageOnly)
icon: const Icon(Icons.photo_camera_back), IconButton(
label: Text('attachmentAddCameraPhoto'.tr), icon: const Icon(Icons.add_road),
tooltip: 'attachmentAddGalleryVideo'.tr,
style: const ButtonStyle(visualDensity: density), 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), onPressed: () => _takeMediaToUpload(false),
), ),
if (!widget.imageOnly) if (!widget.imageOnly && PlatformInfo.isMobile)
ElevatedButton.icon( IconButton(
icon: const Icon(Icons.video_camera_back_outlined), icon: const Icon(Icons.video_camera_back_outlined),
label: Text('attachmentAddCameraVideo'.tr), tooltip: 'attachmentAddCameraVideo'.tr,
style: const ButtonStyle(visualDensity: density), style: const ButtonStyle(visualDensity: density),
onPressed: () => _takeMediaToUpload(true), color: Theme.of(context).colorScheme.primary,
), onPressed: () => _takeMediaToUpload(true),
if (!widget.imageOnly) ),
ElevatedButton.icon( if (!widget.imageOnly)
icon: const Icon(Icons.file_present_rounded), IconButton(
label: Text('attachmentAddFile'.tr), icon: const Icon(Icons.file_present_rounded),
style: const ButtonStyle(visualDensity: density), tooltip: 'attachmentAddFile'.tr,
onPressed: () => _pickFileToUpload(), style: const ButtonStyle(visualDensity: density),
), color: Theme.of(context).colorScheme.primary,
if (!widget.imageOnly) onPressed: () => _pickFileToUpload(),
ElevatedButton.icon( ),
icon: const Icon(Icons.link), if (!widget.imageOnly)
label: Text('attachmentAddFile'.tr), IconButton(
style: const ButtonStyle(visualDensity: density), icon: const Icon(Icons.link),
onPressed: () => _linkAttachments(), tooltip: 'attachmentAddLink'.tr,
), style: const ButtonStyle(visualDensity: density),
], color: Theme.of(context).colorScheme.primary,
).paddingSymmetric(horizontal: 12), onPressed: () => _linkAttachments(),
), ),
],
).paddingSymmetric(horizontal: 12),
) )
.animate( .animate(
target: _uploadController.isUploading.value ? 0 : 1, target: _uploadController.isUploading.value ? 0 : 1,

View File

@ -177,9 +177,6 @@ class _AttachmentListState extends State<AttachmentList> {
if (element == null) return const SizedBox.shrink(); if (element == null) return const SizedBox.shrink();
double ratio = element.metadata?['ratio']?.toDouble() ?? 16 / 9; double ratio = element.metadata?['ratio']?.toDouble() ?? 16 / 9;
return Container( return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: widget.columnMaxWidth, maxWidth: widget.columnMaxWidth,
maxHeight: 640, maxHeight: 640,
@ -247,7 +244,7 @@ class _AttachmentListState extends State<AttachmentList> {
maxHeight: widget.flatMaxHeight, maxHeight: widget.flatMaxHeight,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh, color: Colors.transparent,
border: Border.symmetric( border: Border.symmetric(
horizontal: BorderSide( horizontal: BorderSide(
width: 0.3, width: 0.3,
@ -257,6 +254,7 @@ class _AttachmentListState extends State<AttachmentList> {
), ),
child: CarouselSlider.builder( child: CarouselSlider.builder(
options: CarouselOptions( options: CarouselOptions(
animateToClosest: true,
aspectRatio: _aspectRatio, aspectRatio: _aspectRatio,
viewportFraction: viewportFraction:
widget.viewport ?? (widget.attachmentsId.length > 1 ? 0.95 : 1), widget.viewport ?? (widget.attachmentsId.length > 1 ? 0.95 : 1),
@ -319,6 +317,7 @@ class AttachmentListEntry extends StatelessWidget {
width: width ?? MediaQuery.of(context).size.width, width: width ?? MediaQuery.of(context).size.width,
height: height, height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.transparent,
border: showBorder border: showBorder
? Border.symmetric( ? Border.symmetric(
vertical: BorderSide( vertical: BorderSide(

View File

@ -34,8 +34,17 @@ class AutoCacheImage extends StatelessWidget {
progressIndicatorBuilder: noProgressIndicator progressIndicatorBuilder: noProgressIndicator
? null ? null
: (context, url, downloadProgress) => Center( : (context, url, downloadProgress) => Center(
child: CircularProgressIndicator( child: TweenAnimationBuilder(
value: downloadProgress.progress, 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 errorWidget: noErrorWidget
@ -74,11 +83,20 @@ class AutoCacheImage extends StatelessWidget {
ImageChunkEvent? loadingProgress) { ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child; if (loadingProgress == null) return child;
return Center( return Center(
child: CircularProgressIndicator( child: TweenAnimationBuilder(
value: loadingProgress.expectedTotalBytes != null tween: Tween(
? loadingProgress.cumulativeBytesLoaded / begin: 0,
loadingProgress.expectedTotalBytes! end: loadingProgress.expectedTotalBytes != null
: null, ? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
: 0,
),
duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? value.toDouble()
: null,
),
), ),
); );
}, },

View File

@ -98,12 +98,12 @@ class ChannelCallIndicator extends StatelessWidget {
child: Text('callJoin'.tr), child: Text('callJoin'.tr),
); );
} else if (call.channel.value?.id == channel.id && } else if (call.channel.value?.id == channel.id &&
!AppTheme.isLargeScreen(context)) { !AppTheme.isUltraLargeScreen(context)) {
return TextButton( return TextButton(
onPressed: () => onJoin(), onPressed: () => onJoin(),
child: Text('callResume'.tr), child: Text('callResume'.tr),
); );
} else if (!AppTheme.isLargeScreen(context)) { } else if (!AppTheme.isUltraLargeScreen(context)) {
return TextButton( return TextButton(
onPressed: null, onPressed: null,
child: Text('callJoin'.tr), child: Text('callJoin'.tr),

View File

@ -4,18 +4,18 @@ import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:solian/controllers/chat_events_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/database/database.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:badges/badges.dart' as badges;
class ChannelListWidget extends StatefulWidget { class ChannelListWidget extends StatefulWidget {
final List<Channel> channels; final List<Channel> channels;
final int selfId; final int selfId;
final bool isDense;
final bool isCollapsed;
final bool noCategory;
final bool useReplace; final bool useReplace;
final Function(Channel)? onSelected; final Function(Channel)? onSelected;
@ -23,9 +23,6 @@ class ChannelListWidget extends StatefulWidget {
super.key, super.key,
required this.channels, required this.channels,
required this.selfId, required this.selfId,
this.isDense = false,
this.isCollapsed = false,
this.noCategory = false,
this.useReplace = false, this.useReplace = false,
this.onSelected, this.onSelected,
}); });
@ -35,43 +32,25 @@ class ChannelListWidget extends StatefulWidget {
} }
class _ChannelListWidgetState extends State<ChannelListWidget> { class _ChannelListWidgetState extends State<ChannelListWidget> {
final List<Channel> _globalChannels = List.empty(growable: true); Map<int, LocalMessageEventTableData>? _lastMessages;
final Map<String, List<Channel>> _inRealms = {};
final ChatEventController _eventController = ChatEventController(); final ChatEventController _eventController = ChatEventController();
void _mapChannels() { Future<void> _loadLastMessages() async {
_inRealms.clear(); final messages = await _eventController.src.getLastInAllChannels();
_globalChannels.clear(); setState(() {
_lastMessages = messages
if (widget.noCategory) { .map((k, v) => MapEntry(k, v.firstOrNull))
_globalChannels.addAll(widget.channels); .cast<int, LocalMessageEventTableData>();
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());
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_mapChannels(); _eventController.initialize().then((_) {
_eventController.initialize(); _loadLastMessages();
});
} }
void _gotoChannel(Channel item) { 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) { if (PlatformInfo.isWeb) {
return Text('channelDirectDescription'.trParams( return otherside != null
{'username': '@${otherside.account.name}'}, ? Text(
)); 'channelDirectDescription'.trParams(
{'username': '@${otherside.account.name}'},
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
} }
return FutureBuilder( return AnimatedSwitcher(
future: Future.delayed( switchInCurve: Curves.easeIn,
const Duration(milliseconds: 500), switchOutCurve: Curves.easeOut,
() => _eventController.src.getLastInChannel(item), transitionBuilder: (child, animation) {
), return FadeTransition(opacity: animation, child: child);
builder: (context, snapshot) { },
if (!snapshot.hasData && snapshot.data == null) { duration: const Duration(milliseconds: 300),
return Text('channelDirectDescription'.trParams( child: (_lastMessages == null || _lastMessages![item.id] == null)
{'username': '@${otherside.account.name}'}, ? Builder(
)); builder: (context) {
} return otherside != null
? Text(
final data = snapshot.data!.data!; 'channelDirectDescription'.trParams(
return Text( {'username': '@${otherside.account.name}'},
'${data.sender.account.nick}: ${data.body['text'] ?? 'Unsupported message to preview'}', ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, 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) { Widget _buildEntry(Channel item) {
final padding = widget.isDense const padding = EdgeInsets.symmetric(horizontal: 20);
? const EdgeInsets.symmetric(horizontal: 20)
: const EdgeInsets.symmetric(horizontal: 16);
if (item.type == 1) { final otherside =
final otherside = item.members!.where((e) => e.account.id != widget.selfId).firstOrNull;
item.members!.where((e) => e.account.id != widget.selfId).first;
if (item.type == 1 && otherside != null) {
final avatar = AccountAvatar( final avatar = AccountAvatar(
content: otherside.account.avatar, content: otherside.account.avatar,
radius: widget.isDense ? 12 : 20, radius: 20,
bgColor: Theme.of(context).colorScheme.primary, bgColor: Theme.of(context).colorScheme.primary,
feColor: Theme.of(context).colorScheme.onPrimary, 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( return ListTile(
leading: avatar, leading: avatar,
contentPadding: padding, contentPadding: padding,
title: Text(otherside.account.nick), title: _buildTitle(item, otherside),
subtitle: !widget.isDense subtitle: _buildSubtitle(item, otherside),
? _buildDirectMessageDescription(item, otherside)
: null,
onTap: () => _gotoChannel(item), onTap: () => _gotoChannel(item),
); );
} else { } else {
final avatar = CircleAvatar( final avatar = CircleAvatar(
backgroundColor: item.realmId == null backgroundColor: Theme.of(context).colorScheme.primary,
? Theme.of(context).colorScheme.primary radius: 20,
: Colors.transparent,
radius: widget.isDense ? 12 : 20,
child: FaIcon( child: FaIcon(
FontAwesomeIcons.hashtag, FontAwesomeIcons.hashtag,
color: item.realmId == null color: Theme.of(context).colorScheme.onPrimary,
? Theme.of(context).colorScheme.onPrimary size: 16,
: Theme.of(context).colorScheme.primary,
size: widget.isDense ? 12 : 16,
), ),
); );
if (widget.isCollapsed) {
return Tooltip(
message: item.name,
child: InkWell(
child: avatar.paddingSymmetric(vertical: 12),
onTap: () => _gotoChannel(item),
),
);
}
return ListTile( return ListTile(
minTileHeight: widget.isDense ? 48 : null, minTileHeight: null,
leading: avatar, 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, contentPadding: padding,
title: Text(item.name), title: _buildTitle(item, null),
subtitle: !widget.isDense subtitle: _buildSubtitle(item, null),
? Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
: null,
onTap: () => _gotoChannel(item), onTap: () => _gotoChannel(item),
); );
} }
@ -206,49 +261,16 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
@override @override
Widget build(BuildContext context) { 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( return CustomScrollView(
slivers: [ slivers: [
SliverList.builder( SliverList.builder(
itemCount: _globalChannels.length, itemCount: widget.channels.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final element = _globalChannels[index]; final element = widget.channels[index];
return _buildEntry(element); return _buildEntry(element);
}, },
), ),
SliverList.list( SliverGap(max(16, MediaQuery.of(context).padding.bottom)),
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(),
),
], ],
); );
} }

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/controllers/chat_events_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/models/channel.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'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class ChatEventList extends StatelessWidget { class ChatEventList extends StatelessWidget {
final bool noAnimated;
final String scope; final String scope;
final Channel channel; final Channel channel;
final ChatEventController chatController; final ChatEventController chatController;
@ -23,6 +25,7 @@ class ChatEventList extends StatelessWidget {
required this.chatController, required this.chatController,
required this.onEdit, required this.onEdit,
required this.onReply, required this.onReply,
this.noAnimated = false,
}); });
bool _checkMessageMergeable(Event? a, Event? b) { bool _checkMessageMergeable(Event? a, Event? b) {
@ -63,15 +66,32 @@ class ChatEventList extends StatelessWidget {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: ChatEvent( child: Builder(builder: (context) {
key: Key('m${item!.uuid}'), final widget = ChatEvent(
item: item, key: Key('m${item!.uuid}'),
isMerged: isMerged, item: item,
chatController: chatController, isMerged: isMerged,
).paddingOnly( chatController: chatController,
top: !isMerged ? 8 : 0, ).paddingOnly(
bottom: !hasMerged ? 8 : 0, 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: () { onLongPress: () {
showModalBottomSheet( showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,
@ -79,7 +99,7 @@ class ChatEventList extends StatelessWidget {
builder: (context) => ChatEventAction( builder: (context) => ChatEventAction(
channel: channel, channel: channel,
realm: channel.realm, realm: channel.realm,
item: item, item: item!,
onEdit: () { onEdit: () {
onEdit(item); onEdit(item);
}, },

View File

@ -49,6 +49,7 @@ class ChatEventMessage extends StatelessWidget {
return MarkdownTextContent( return MarkdownTextContent(
parentId: 'm${item.id}', parentId: 'm${item.id}',
isSelectable: true, isSelectable: true,
isAutoWarp: true,
content: body.text, content: body.text,
); );
} }

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown_selectionarea/flutter_markdown.dart'; import 'package:flutter_markdown_selectionarea/flutter_markdown.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:markdown/markdown.dart' as markdown; import 'package:markdown/markdown.dart' as markdown;
import 'package:markdown/markdown.dart'; import 'package:markdown/markdown.dart';
@ -15,6 +16,7 @@ class MarkdownTextContent extends StatelessWidget {
final String parentId; final String parentId;
final bool isSelectable; final bool isSelectable;
final bool isLargeText; final bool isLargeText;
final bool isAutoWarp;
const MarkdownTextContent({ const MarkdownTextContent({
super.key, super.key,
@ -22,139 +24,175 @@ class MarkdownTextContent extends StatelessWidget {
required this.parentId, required this.parentId,
this.isSelectable = false, this.isSelectable = false,
this.isLargeText = false, this.isLargeText = false,
this.isAutoWarp = false,
}); });
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
final emojiRegex = RegExp(r':([-\w]+):'); final stickerRegex = RegExp(r':([-\w]+):');
final emojiMatch = emojiRegex.allMatches(content);
final isOnlyEmoji = content.replaceAll(emojiRegex, '').trim().isEmpty;
return Markdown( // Split the content into paragraphs
shrinkWrap: true, final paragraphs = content.split(RegExp(r'\n\s*\n'));
physics: const NeverScrollableScrollPhysics(),
data: content, // Iterate over each paragraph to process stickers individually
padding: EdgeInsets.zero, List<Widget> contentWidgets = [];
styleSheet: MarkdownStyleSheet.fromTheme( for (var idx = 0; idx < paragraphs.length; idx++) {
Theme.of(context), // Getting paragraph
).copyWith( var paragraph = paragraphs[idx];
textScaleFactor: isLargeText ? 1.1 : 1,
blockquote: TextStyle( // Auto adding new-lines
color: Theme.of(context).colorScheme.onSurfaceVariant, if (isAutoWarp) {
), paragraph = paragraph.replaceAll('\n', '\\\n');
blockquoteDecoration: BoxDecoration( }
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.all(Radius.circular(4)), // Matching stickers
), final stickerMatch = stickerRegex.allMatches(paragraph);
horizontalRuleDecoration: BoxDecoration( final isOnlySticker =
border: Border( paragraph.replaceAll(stickerRegex, '').trim().isEmpty;
top: BorderSide(
width: 1.0, contentWidgets.add(
color: Theme.of(context).dividerColor, 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,
extensionSet: markdown.ExtensionSet( <markdown.InlineSyntax>[
markdown.ExtensionSet.gitHubFlavored.blockSyntaxes, _UserNameCardInlineSyntax(),
<markdown.InlineSyntax>[ _CustomEmoteInlineSyntax(),
_UserNameCardInlineSyntax(), markdown.EmojiSyntax(),
_CustomEmoteInlineSyntax(), markdown.AutolinkSyntax(),
markdown.EmojiSyntax(), markdown.AutolinkExtensionSyntax(),
markdown.AutolinkSyntax(), ...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes
markdown.AutolinkExtensionSyntax(), ],
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes ),
], onTapLink: (text, href, title) async {
), if (href == null) return;
onTapLink: (text, href, title) async { if (href.startsWith('solink://')) {
if (href == null) return; final segments = href.replaceFirst('solink://', '').split('/');
if (href.startsWith('solink://')) { switch (segments[0]) {
final segments = href.replaceFirst('solink://', '').split('/'); case 'users':
switch (segments[0]) { showModalBottomSheet(
case 'users': useRootNavigator: true,
showModalBottomSheet( isScrollControlled: true,
useRootNavigator: true, backgroundColor: Theme.of(context).colorScheme.surface,
isScrollControlled: true, context: context,
backgroundColor: Theme.of(context).colorScheme.surface, builder: (context) => AccountProfilePopup(
context: context, name: segments[1],
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;
} }
fit = BoxFit.contain; return;
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 AutoCacheImage( await launchUrlString(
url, href,
width: width, mode: LaunchMode.externalApplication,
height: height, );
fit: fit, },
); 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,
); );
} }

View 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'],
),
),
);
});
}
}

View File

@ -1,27 +1,33 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/utils.dart'; import 'package:get/utils.dart';
import 'package:solian/widgets/navigation/app_account_widget.dart';
abstract class AppNavigation { abstract class AppNavigation {
static List<AppNavigationDestination> destinations = [ static List<AppNavigationDestination> destinations = [
AppNavigationDestination( AppNavigationDestination(
icon: Icons.dashboard, icon: const Icon(Icons.dashboard),
label: 'dashboard'.tr, label: 'dashboardNav'.tr,
page: 'dashboard', page: 'dashboard',
), ),
AppNavigationDestination( AppNavigationDestination(
icon: Icons.newspaper, icon: const Icon(Icons.explore),
label: 'feed'.tr, label: 'explore'.tr,
page: 'feed', page: 'explore',
), ),
AppNavigationDestination( AppNavigationDestination(
icon: Icons.workspaces, icon: const Icon(Icons.forum),
label: 'chat'.tr,
page: 'chat',
),
AppNavigationDestination(
icon: const Icon(Icons.workspaces),
label: 'realms'.tr, label: 'realms'.tr,
page: 'realms', page: 'realms',
), ),
AppNavigationDestination( AppNavigationDestination(
icon: Icons.forum, icon: const AppAccountWidget(),
label: 'chat'.tr, label: 'accountNav'.tr,
page: 'chat', page: 'account',
), ),
]; ];
@ -30,7 +36,7 @@ abstract class AppNavigation {
} }
class AppNavigationDestination { class AppNavigationDestination {
final IconData icon; final Widget icon;
final String label; final String label;
final String page; final String page;

View 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);
},
);
}
}

View File

@ -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),
),
],
),
),
);
}
}

View 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),
);
}
}

View File

@ -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(),
),
),
),
],
),
),
);
}
}

View 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,
),
),
);
});
}
}

View File

@ -29,7 +29,7 @@ class _PostEditorThumbnailDialogState extends State<PostEditorThumbnailDialog> {
_attachmentController.text = value.toString(); _attachmentController.text = value.toString();
}); });
widget.controller.thumbnail.value = value; widget.controller.thumbnail.value = value.isEmpty ? null : value;
}, },
initialAttachments: const [], initialAttachments: const [],
onRemove: (_) {}, onRemove: (_) {},
@ -91,7 +91,8 @@ class _PostEditorThumbnailDialogState extends State<PostEditorThumbnailDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () {
widget.controller.thumbnail.value = _attachmentController.text; final text = _attachmentController.text;
widget.controller.thumbnail.value = text.isEmpty ? null : text;
Navigator.pop(context); Navigator.pop(context);
}, },
child: Text('confirm'.tr), child: Text('confirm'.tr),

View File

@ -18,6 +18,7 @@ import 'package:solian/widgets/link_expansion.dart';
import 'package:solian/widgets/markdown_text_content.dart'; import 'package:solian/widgets/markdown_text_content.dart';
import 'package:solian/widgets/posts/post_tags.dart'; import 'package:solian/widgets/posts/post_tags.dart';
import 'package:solian/widgets/posts/post_quick_action.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:solian/widgets/sized_container.dart';
import 'package:timeago/timeago.dart' show format; import 'package:timeago/timeago.dart' show format;
@ -69,360 +70,6 @@ class _PostItemState extends State<PostItem> {
super.initState(); 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, top: 4, bottom: 4);
} 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; double _contentHeight = 0;
@override @override
@ -436,9 +83,15 @@ class _PostItemState extends State<PostItem> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildThumbnail().paddingOnly(bottom: 8), _PostThumbnail(
_buildHeader().paddingSymmetric(horizontal: 12), rid: item.body['thumbnail'],
_buildHeaderDivider().paddingSymmetric(horizontal: 12), parentId: widget.item.id.toString(),
).paddingOnly(bottom: 8),
_PostHeaderWidget(
isCompact: widget.isCompact,
item: item,
).paddingSymmetric(horizontal: 12),
_PostHeaderDividerWidget(item: item).paddingSymmetric(horizontal: 12),
Stack( Stack(
children: [ children: [
SizedContainer( SizedContainer(
@ -448,10 +101,14 @@ class _PostItemState extends State<PostItem> {
onChange: (size) { onChange: (size) {
setState(() => _contentHeight = size.height); setState(() => _contentHeight = size.height);
}, },
child: MarkdownTextContent( child: SingleChildScrollView(
parentId: 'p${item.id}', physics: const NeverScrollableScrollPhysics(),
content: item.body['content'], child: MarkdownTextContent(
isSelectable: widget.isContentSelectable, parentId: 'p${item.id}',
content: item.body['content'],
isAutoWarp: item.type == 'story',
isSelectable: widget.isContentSelectable,
),
).paddingOnly( ).paddingOnly(
left: 16, left: 16,
right: 12, 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( LinkExpansion(content: item.body['content']).paddingOnly(
left: 8, left: 8,
right: 8, right: 8,
top: 4, top: 4,
), ),
_buildFooter().paddingOnly(left: 16), _PostFooterWidget(item: item).paddingOnly(left: 16),
if (attachments.isNotEmpty) if (attachments.isNotEmpty)
Row( Row(
children: [ children: [
@ -515,7 +158,10 @@ class _PostItemState extends State<PostItem> {
closedBuilder: (_, openContainer) => Column( closedBuilder: (_, openContainer) => Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildThumbnail().paddingOnly(bottom: 4), _PostThumbnail(
rid: item.body['thumbnail'],
parentId: widget.item.id.toString(),
).paddingOnly(bottom: 4),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -537,8 +183,11 @@ class _PostItemState extends State<PostItem> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildHeader(), _PostHeaderWidget(
_buildHeaderDivider(), isCompact: widget.isCompact,
item: item,
),
_PostHeaderDividerWidget(item: item),
Stack( Stack(
children: [ children: [
SizedContainer( SizedContainer(
@ -549,52 +198,60 @@ class _PostItemState extends State<PostItem> {
onChange: (size) { onChange: (size) {
setState(() => _contentHeight = size.height); setState(() => _contentHeight = size.height);
}, },
child: MarkdownTextContent( child: SingleChildScrollView(
parentId: 'p${item.id}-embed', physics: const NeverScrollableScrollPhysics(),
content: item.body['content'], child: MarkdownTextContent(
isSelectable: widget.isContentSelectable, parentId: 'p${item.id}-embed',
isLargeText: item.type == 'article' && content: item.body['content'],
widget.isFullContent, isAutoWarp: item.type == 'story',
).paddingOnly(left: 12, right: 8), isSelectable: widget.isContentSelectable,
), isLargeText: item.type == 'article' &&
), widget.isFullContent,
if (_contentHeight >= 320 && !widget.isFullContent) ).paddingOnly(left: 12, right: 8),
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),
],
),
),
),
), ),
), ),
),
], ],
), ),
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) if (widget.item.replyTo != null && widget.isShowEmbed)
Container( Container(
constraints: const BoxConstraints(maxWidth: 480), constraints: const BoxConstraints(maxWidth: 480),
padding: const EdgeInsets.only(top: 4), 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) if (widget.item.repostTo != null && widget.isShowEmbed)
Container( Container(
constraints: const BoxConstraints(maxWidth: 480), constraints: const BoxConstraints(maxWidth: 480),
padding: const EdgeInsets.only(top: 4), 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']) LinkExpansion(content: item.body['content'])
.paddingOnly(top: 4), .paddingOnly(top: 4),
], ],
@ -610,8 +267,8 @@ class _PostItemState extends State<PostItem> {
right: 16, right: 16,
left: 16, left: 16,
), ),
_buildAttachments(), _PostAttachmentWidget(item: item),
if (widget.showFeaturedReply) _buildFeaturedReply(), if (widget.showFeaturedReply) _PostFeaturedReplyWidget(item: item),
if (widget.isShowReply || widget.isReactable) if (widget.isShowReply || widget.isReactable)
PostQuickAction( PostQuickAction(
isShowReply: widget.isShowReply, isShowReply: widget.isShowReply,
@ -647,13 +304,406 @@ class _PostItemState extends State<PostItem> {
), ),
closedElevation: 0, closedElevation: 0,
openElevation: 0, openElevation: 0,
closedColor: closedColor: Colors.transparent,
widget.backgroundColor ?? Theme.of(context).colorScheme.surface,
openColor: Theme.of(context).colorScheme.surface, 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); typedef _OnWidgetSizeChange = void Function(Size size);
class _MeasureSizeRenderObject extends RenderProxyBox { class _MeasureSizeRenderObject extends RenderProxyBox {

View File

@ -27,7 +27,7 @@ class PostTagsList extends StatelessWidget {
), ),
), ),
onTap: () { onTap: () {
AppRouter.instance.pushNamed('feedSearch', queryParameters: { AppRouter.instance.pushNamed('postSearch', queryParameters: {
'tag': x.alias, 'tag': x.alias,
}); });
}, },

View 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',
),
);
}
}

View 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,
);
},
);
}
}

View File

@ -1,14 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:solian/widgets/root_container.dart';
class EmptyPagePlaceholder extends StatelessWidget { class EmptyPagePlaceholder extends StatelessWidget {
const EmptyPagePlaceholder({super.key}); const EmptyPagePlaceholder({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return RootContainer(
color: Theme.of(context).colorScheme.surface,
child: Center( 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),
),
), ),
); );
} }

View File

@ -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),
),
);
}
}

View File

@ -17,6 +17,7 @@ import flutter_local_notifications
import flutter_secure_storage_macos import flutter_secure_storage_macos
import flutter_webrtc import flutter_webrtc
import gal import gal
import in_app_review
import livekit_client import livekit_client
import macos_window_utils import macos_window_utils
import media_kit_libs_macos_video import media_kit_libs_macos_video
@ -46,6 +47,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin")) GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))
LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin")) LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))

View File

@ -158,6 +158,8 @@ PODS:
- GoogleUtilities/UserDefaults (8.0.2): - GoogleUtilities/UserDefaults (8.0.2):
- GoogleUtilities/Logger - GoogleUtilities/Logger
- GoogleUtilities/Privacy - GoogleUtilities/Privacy
- in_app_review (0.2.0):
- FlutterMacOS
- livekit_client (2.2.6): - livekit_client (2.2.6):
- FlutterMacOS - FlutterMacOS
- WebRTC-SDK (= 125.6422.04) - WebRTC-SDK (= 125.6422.04)
@ -234,6 +236,7 @@ DEPENDENCIES:
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
- gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`) - 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`) - livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/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`) - 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 :path: Flutter/ephemeral
gal: gal:
:path: Flutter/ephemeral/.symlinks/plugins/gal/darwin :path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
in_app_review:
:path: Flutter/ephemeral/.symlinks/plugins/in_app_review/macos
livekit_client: livekit_client:
:path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos :path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos
macos_window_utils: macos_window_utils:
@ -336,7 +341,7 @@ SPEC CHECKSUMS:
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720
file_selector_macos: 54fdab7caa3ac3fc43c9fac4d7d8d231277f8cf2 file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
Firebase: 9f574c08c2396885b5e7e100ed4293d956218af9 Firebase: 9f574c08c2396885b5e7e100ed4293d956218af9
firebase_analytics: a2d0d907566e4a48e27745317f05b4b7db85edd9 firebase_analytics: a2d0d907566e4a48e27745317f05b4b7db85edd9
firebase_core: c55630cdb8a01cf49eae741dd4bc8c93bdd546b8 firebase_core: c55630cdb8a01cf49eae741dd4bc8c93bdd546b8
@ -359,6 +364,7 @@ SPEC CHECKSUMS:
GoogleAppMeasurement: 6e49ffac7d3f2c3ded9cc663f912a13b67bbd0de GoogleAppMeasurement: 6e49ffac7d3f2c3ded9cc663f912a13b67bbd0de
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
in_app_review: a850789fad746e89bce03d4aeee8078b45a53fd0
livekit_client: 98d09566e3a936b3402be8091ec3845556d36800 livekit_client: 98d09566e3a936b3402be8091ec3845556d36800
macos_window_utils: 933f91f64805e2eb91a5bd057cf97cd097276663 macos_window_utils: 933f91f64805e2eb91a5bd057cf97cd097276663
media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82 media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
@ -377,7 +383,7 @@ SPEC CHECKSUMS:
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
sqlite3_flutter_libs: 5ca46c1a04eddfbeeb5b16566164aa7ad1616e7b sqlite3_flutter_libs: 5ca46c1a04eddfbeeb5b16566164aa7ad1616e7b
url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399 url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269 wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
WebRTC-SDK: c3d69a87e7185fad3568f6f3cff7c9ac5890acf3 WebRTC-SDK: c3d69a87e7185fad3568f6f3cff7c9ac5890acf3

View File

@ -12,8 +12,6 @@
<true/> <true/>
<key>com.apple.security.device.audio-input</key> <key>com.apple.security.device.audio-input</key>
<true/> <true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
<key>com.apple.security.files.user-selected.read-only</key> <key>com.apple.security.files.user-selected.read-only</key>

View File

@ -47,10 +47,21 @@
<string>MainMenu</string> <string>MainMenu</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
<key>CFBundleLocalizations</key>
<array>
<string>zh_CN</string>
<string>en</string>
</array>
<key>NSUserActivityTypes</key> <key>NSUserActivityTypes</key>
<array> <array>
<string>INStartCallIntent</string> <string>INStartCallIntent</string>
<string>INSendMessageIntent</string> <string>INSendMessageIntent</string>
</array> </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> </dict>
</plist> </plist>

View File

@ -10,8 +10,6 @@
<true/> <true/>
<key>com.apple.security.device.audio-input</key> <key>com.apple.security.device.audio-input</key>
<true/> <true/>
<key>com.apple.security.device.bluetooth</key>
<true/>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
<key>com.apple.security.files.user-selected.read-only</key> <key>com.apple.security.files.user-selected.read-only</key>

View File

@ -474,10 +474,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file_selector_macos name: file_selector_macos
sha256: f42eacb83b318e183b1ae24eead1373ab1334084404c8c16e0354f9a3e55d385 sha256: cb284e267f8e2a45a904b5c094d2ba51d0aabfc20b1538ab786d9ef7dc2bf75c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.4" version: "0.9.4+1"
file_selector_platform_interface: file_selector_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1125,6 +1125,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.1+1" 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: infinite_scroll_pagination:
dependency: "direct main" dependency: "direct main"
description: description:
@ -2078,10 +2094,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_macos name: url_launcher_macos
sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" sha256: "769549c999acdb42b8bcfa7c43d72bf79a382ca7441ab18a808e101149daf672"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.0" version: "3.2.1"
url_launcher_platform_interface: url_launcher_platform_interface:
dependency: transitive dependency: transitive
description: description:

View File

@ -2,7 +2,7 @@ name: solian
description: "The Solar Network App" description: "The Solar Network App"
publish_to: "none" publish_to: "none"
version: 1.2.3+1 version: 1.3.6+3
environment: environment:
sdk: ">=3.3.4 <4.0.0" sdk: ">=3.3.4 <4.0.0"
@ -83,6 +83,7 @@ dependencies:
flutter_app_update: ^3.1.0 flutter_app_update: ^3.1.0
version: ^3.0.2 version: ^3.0.2
action_slider: ^0.7.0 action_slider: ^0.7.0
in_app_review: ^2.0.9
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: