Compare commits

...

24 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
65 changed files with 1961 additions and 1527 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

@ -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",
@ -457,5 +459,23 @@
"serviceStatus": "Status of Service", "serviceStatus": "Status of Service",
"firstBootTime": "First boot at @time", "firstBootTime": "First boot at @time",
"rateTheApp": "Rate the app", "rateTheApp": "Rate the app",
"rateTheAppDesc": "Rate Solar Network on the App Store to let us serve you better!" "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

@ -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": "频道设置",
@ -453,5 +455,23 @@
"serviceStatus": "服务状态", "serviceStatus": "服务状态",
"firstBootTime": "首次启动于 @time", "firstBootTime": "首次启动于 @time",
"rateTheApp": "给应用评分", "rateTheApp": "给应用评分",
"rateTheAppDesc": "在 App Store 上给 Solar Network 评分,让我们更好地为您服务吧!" "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

@ -12,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';
@ -198,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)
@ -258,8 +256,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
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

@ -29,6 +29,8 @@ abstract class PlatformInfo {
static bool get canRateTheApp => 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;

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

@ -28,6 +28,8 @@ 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(
@ -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

@ -4,6 +4,7 @@ import 'package:get/get.dart';
import 'package:intl/intl.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: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';
@ -15,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(

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,11 +10,12 @@ 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 ExploreScreen extends StatefulWidget { class ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key}); const ExploreScreen({super.key});
@ -55,10 +56,8 @@ class _ExploreScreenState extends State<ExploreScreen>
@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 _ExploreScreenState extends State<ExploreScreen>
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [ return [
SliverAppBar( SliverAppBar(
title: AppBarTitle('explore'.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 _ExploreScreenState extends State<ExploreScreen>
], ],
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 _ExploreScreenState extends State<ExploreScreen>
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

@ -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,17 +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: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});
@ -22,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(
@ -32,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) {
@ -75,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,
@ -181,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),

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

@ -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

@ -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.explore, icon: const Icon(Icons.explore),
label: 'explore'.tr, label: 'explore'.tr,
page: 'explore', 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

@ -117,30 +117,16 @@ 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,
@ -225,34 +211,16 @@ class _PostItemState extends State<PostItem> {
), ),
), ),
), ),
if (_contentHeight >= 320 && !widget.isFullContent)
Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: Container(
height: 320,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
(widget.backgroundColor ??
Theme.of(context)
.colorScheme
.surface),
(widget.backgroundColor ??
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),
@ -336,8 +304,7 @@ 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,
); );
} }
@ -574,7 +541,7 @@ class _PostEmbedWidget extends StatelessWidget {
), ),
closedElevation: 0, closedElevation: 0,
openElevation: 0, openElevation: 0,
closedColor: Theme.of(context).colorScheme.surface, closedColor: Colors.transparent,
openColor: Theme.of(context).colorScheme.surface, openColor: Theme.of(context).colorScheme.surface,
); );
} }

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

@ -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

@ -57,5 +57,11 @@
<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

@ -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.4+1 version: 1.3.6+3
environment: environment:
sdk: ">=3.3.4 <4.0.0" sdk: ">=3.3.4 <4.0.0"