Compare commits
10 Commits
ac60043ca7
...
1.3.6+1
| Author | SHA1 | Date | |
|---|---|---|---|
| 11c913af60 | |||
| db8f0d63e1 | |||
| 4036a79995 | |||
| 859bbd09e0 | |||
| 60033fdef3 | |||
| 9c3d181deb | |||
| 9e6829bd5a | |||
| f50461a7f7 | |||
| 147879e4d8 | |||
| f353c05cb5 |
@@ -4,3 +4,4 @@ android.enableJetifier=true
|
||||
android.defaults.buildfeatures.buildconfig=true
|
||||
android.nonTransitiveRClass=false
|
||||
android.nonFinalResIds=false
|
||||
kotlin.jvm.target.validation.mode = IGNORE
|
||||
|
||||
@@ -463,5 +463,10 @@
|
||||
"friendAdd": "Add as friend",
|
||||
"blockUser": "Block user",
|
||||
"unblockUser": "Unblock user",
|
||||
"learnMoreAboutPerson": "Learn more about that person"
|
||||
"learnMoreAboutPerson": "Learn more about that person",
|
||||
"global": "Global",
|
||||
"all": "All",
|
||||
"unablePreview": "Unable to preview",
|
||||
"dashboardNav": "Dash",
|
||||
"accountNav": "You"
|
||||
}
|
||||
|
||||
@@ -459,5 +459,10 @@
|
||||
"friendAdd": "添加好友",
|
||||
"blockUser": "屏蔽用户",
|
||||
"unblockUser": "解除屏蔽用户",
|
||||
"learnMoreAboutPerson": "了解关于 TA 的更多"
|
||||
"learnMoreAboutPerson": "了解关于 TA 的更多",
|
||||
"global": "全局",
|
||||
"all": "全部",
|
||||
"unablePreview": "无法预览",
|
||||
"dashboardNav": "仪表盘",
|
||||
"accountNav": "您"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import 'package:solian/exceptions/request.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/channel.dart';
|
||||
import 'package:solian/providers/content/realm.dart';
|
||||
import 'package:solian/providers/relation.dart';
|
||||
import 'package:solian/providers/theme_switcher.dart';
|
||||
@@ -198,8 +197,6 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
|
||||
final AuthProvider auth = Get.find();
|
||||
try {
|
||||
await Future.wait([
|
||||
if (auth.isAuthorized.isTrue)
|
||||
Get.find<ChannelProvider>().refreshAvailableChannel(),
|
||||
if (auth.isAuthorized.isTrue)
|
||||
Get.find<RelationshipProvider>().refreshRelativeList(),
|
||||
if (auth.isAuthorized.isTrue)
|
||||
|
||||
@@ -29,6 +29,8 @@ abstract class PlatformInfo {
|
||||
|
||||
static bool get canRateTheApp => isIOS || isMacOS;
|
||||
|
||||
static bool get canCropImage => isIOS || isAndroid || isWeb;
|
||||
|
||||
static bool get canRecord => (isMobile || isMacOS);
|
||||
|
||||
static bool get canPushNotification => isAndroid || isIOS || isMacOS;
|
||||
|
||||
@@ -392,7 +392,7 @@ class ChatCallProvider extends GetxController {
|
||||
}
|
||||
|
||||
Future gotoScreen(BuildContext context) {
|
||||
return Navigator.of(context, rootNavigator: true).push(
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const CallScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,25 +9,6 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChannelProvider extends GetxController {
|
||||
RxBool isLoading = false.obs;
|
||||
RxList<Channel> availableChannels = RxList.empty(growable: true);
|
||||
|
||||
List<Channel> get groupChannels =>
|
||||
availableChannels.where((x) => x.type == 0).toList();
|
||||
List<Channel> get directChannels =>
|
||||
availableChannels.where((x) => x.type == 1).toList();
|
||||
|
||||
Future<void> refreshAvailableChannel() async {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
|
||||
|
||||
isLoading.value = true;
|
||||
final resp = await listAvailableChannel();
|
||||
isLoading.value = false;
|
||||
|
||||
availableChannels.value =
|
||||
resp.body.map((x) => Channel.fromJson(x)).toList().cast<Channel>();
|
||||
availableChannels.refresh();
|
||||
}
|
||||
|
||||
Future<Response> getChannel(String alias, {String realm = 'global'}) async {
|
||||
final AuthProvider auth = Get.find();
|
||||
@@ -89,18 +70,22 @@ class ChannelProvider extends GetxController {
|
||||
return resp;
|
||||
}
|
||||
|
||||
Future<Response> listAvailableChannel({String scope = 'global'}) async {
|
||||
Future<List<Channel>> listAvailableChannel({
|
||||
String scope = 'global',
|
||||
bool isDirect = false,
|
||||
}) async {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
|
||||
|
||||
final client = await auth.configureClient('messaging');
|
||||
|
||||
final resp = await client.get('/channels/$scope/me/available');
|
||||
final resp =
|
||||
await client.get('/channels/$scope/me/available?direct=$isDirect');
|
||||
if (resp.statusCode != 200) {
|
||||
throw RequestException(resp);
|
||||
}
|
||||
|
||||
return resp;
|
||||
return List.from(resp.body.map((x) => Channel.fromJson(x)));
|
||||
}
|
||||
|
||||
Future<Response> createChannel(String scope, dynamic payload) async {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:get/get.dart' hide Value;
|
||||
import 'package:solian/exceptions/request.dart';
|
||||
@@ -182,4 +185,26 @@ class MessagesFetchingProvider extends GetxController {
|
||||
..orderBy([(t) => OrderingTerm.desc(t.id)]))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<Map<int, List<LocalMessageEventTableData>>>
|
||||
getLastInAllChannels() async {
|
||||
final database = Get.find<DatabaseProvider>().database;
|
||||
final rows = await database.customSelect('''
|
||||
SELECT id, channel_id, data, created_at
|
||||
FROM ${database.localMessageEventTable.actualTableName}
|
||||
WHERE (channel_id, created_at) IN (
|
||||
SELECT channel_id, MAX(created_at)
|
||||
FROM ${database.localMessageEventTable.actualTableName}
|
||||
GROUP BY channel_id
|
||||
)
|
||||
''', readsFrom: {database.localMessageEventTable}).get();
|
||||
return rows.map((row) {
|
||||
return LocalMessageEventTableData(
|
||||
id: row.read<int>('id'),
|
||||
channelId: row.read<int>('channel_id'),
|
||||
data: Event.fromJson(jsonDecode(row.read<String>('data'))),
|
||||
createdAt: row.read<DateTime>('created_at'),
|
||||
);
|
||||
}).groupListsBy((x) => x.channelId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import 'package:solian/screens/posts/post_editor.dart';
|
||||
import 'package:solian/screens/settings.dart';
|
||||
import 'package:solian/shells/root_shell.dart';
|
||||
import 'package:solian/shells/title_shell.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/sidebar/empty_placeholder.dart';
|
||||
|
||||
abstract class AppRouter {
|
||||
static GoRouter instance = GoRouter(
|
||||
@@ -137,12 +139,15 @@ abstract class AppRouter {
|
||||
);
|
||||
|
||||
static final ShellRoute _chatRoute = ShellRoute(
|
||||
builder: (context, state, child) => child,
|
||||
builder: (context, state, child) =>
|
||||
AppTheme.isLargeScreen(context) ? ChatListShell(child: child) : child,
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
builder: (context, state) => const ChatScreen(),
|
||||
builder: (context, state) => AppTheme.isLargeScreen(context)
|
||||
? const EmptyPagePlaceholder()
|
||||
: const ChatScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/chat/organize',
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
@@ -9,6 +7,7 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/attachment.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/attachment.dart';
|
||||
import 'package:solian/services.dart';
|
||||
@@ -77,36 +76,42 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (auth.isAuthorized.isFalse) return;
|
||||
|
||||
XFile file;
|
||||
|
||||
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
|
||||
if (image == null) return;
|
||||
|
||||
CroppedFile? croppedFile = await ImageCropper().cropImage(
|
||||
sourcePath: image.path,
|
||||
uiSettings: [
|
||||
AndroidUiSettings(
|
||||
toolbarTitle: 'cropImage'.tr,
|
||||
toolbarColor: Theme.of(context).colorScheme.primary,
|
||||
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
IOSUiSettings(
|
||||
title: 'cropImage'.tr,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
WebUiSettings(
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (PlatformInfo.canCropImage) {
|
||||
CroppedFile? croppedFile = await ImageCropper().cropImage(
|
||||
sourcePath: image.path,
|
||||
uiSettings: [
|
||||
AndroidUiSettings(
|
||||
toolbarTitle: 'cropImage'.tr,
|
||||
toolbarColor: Theme.of(context).colorScheme.primary,
|
||||
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
IOSUiSettings(
|
||||
title: 'cropImage'.tr,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
WebUiSettings(
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (croppedFile == null) return;
|
||||
final file = File(croppedFile.path);
|
||||
if (croppedFile == null) return;
|
||||
file = XFile(croppedFile.path);
|
||||
} else {
|
||||
file = XFile(image.path);
|
||||
}
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import 'package:solian/exceptions/request.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/auth.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/channel.dart';
|
||||
import 'package:solian/providers/content/realm.dart';
|
||||
import 'package:solian/providers/relation.dart';
|
||||
import 'package:solian/providers/websocket.dart';
|
||||
@@ -177,7 +176,6 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||
await auth.refreshAuthorizeStatus();
|
||||
await auth.refreshUserProfile();
|
||||
|
||||
Get.find<ChannelProvider>().refreshAvailableChannel();
|
||||
Get.find<RealmProvider>().refreshAvailableRealms();
|
||||
Get.find<RelationshipProvider>().refreshRelativeList();
|
||||
Get.find<WebSocketProvider>().registerPushNotifications();
|
||||
|
||||
@@ -275,7 +275,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
|
||||
channel: _channel!,
|
||||
ongoingCall: _ongoingCall!,
|
||||
onJoin: () {
|
||||
if (!AppTheme.isLargeScreen(context)) {
|
||||
if (!AppTheme.isUltraLargeScreen(context)) {
|
||||
final ChatCallProvider call = Get.find();
|
||||
call.gotoScreen(context);
|
||||
}
|
||||
@@ -329,7 +329,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
|
||||
),
|
||||
Obx(() {
|
||||
final ChatCallProvider call = Get.find();
|
||||
if (call.isMounted.value && AppTheme.isLargeScreen(context)) {
|
||||
if (call.isMounted.value &&
|
||||
AppTheme.isUltraLargeScreen(context)) {
|
||||
return const Expanded(
|
||||
child: Row(children: [
|
||||
VerticalDivider(width: 0.3, thickness: 0.3),
|
||||
|
||||
@@ -1,145 +1,322 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/controllers/chat_events_controller.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/channel.dart';
|
||||
import 'package:solian/providers/content/realm.dart';
|
||||
import 'package:solian/providers/database/database.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/screens/account/notification.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/account/signin_required_overlay.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/channel/channel_list.dart';
|
||||
import 'package:solian/widgets/chat/call/chat_call_indicator.dart';
|
||||
import 'package:solian/widgets/current_state_action.dart';
|
||||
import 'package:solian/widgets/sized_container.dart';
|
||||
import 'package:solian/widgets/sidebar/empty_placeholder.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
class ChatScreen extends StatelessWidget {
|
||||
const ChatScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: const ChatList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
late final ChannelProvider _channels;
|
||||
class ChatListShell extends StatelessWidget {
|
||||
final Widget? child;
|
||||
|
||||
const ChatListShell({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 360,
|
||||
child: ChatList(),
|
||||
),
|
||||
const VerticalDivider(thickness: 0.3, width: 0.3),
|
||||
Expanded(child: child ?? const EmptyPagePlaceholder()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatList extends StatefulWidget {
|
||||
const ChatList({super.key});
|
||||
|
||||
@override
|
||||
State<ChatList> createState() => _ChatListState();
|
||||
}
|
||||
|
||||
class _ChatListState extends State<ChatList> {
|
||||
List<Channel> _normalChannels = List.empty();
|
||||
List<Channel> _directChannels = List.empty();
|
||||
final Map<String, List<Channel>> _realmChannels = {};
|
||||
|
||||
late final ChannelProvider _channels = Get.find();
|
||||
|
||||
List<Channel> _sortChannels(List<Channel> channels) {
|
||||
channels.sort(
|
||||
(a, b) =>
|
||||
_lastMessages?[b.id]?.createdAt.compareTo(
|
||||
_lastMessages?[a.id]?.createdAt ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
) ??
|
||||
0,
|
||||
);
|
||||
return channels;
|
||||
}
|
||||
|
||||
Future<void> _loadNormalChannels() async {
|
||||
final resp = await _channels.listAvailableChannel(isDirect: false);
|
||||
setState(() {
|
||||
_normalChannels = _sortChannels(resp);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadDirectChannels() async {
|
||||
final resp = await _channels.listAvailableChannel(isDirect: true);
|
||||
setState(() {
|
||||
_directChannels = _sortChannels(resp);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadRealmChannels(String realm) async {
|
||||
final resp = await _channels.listAvailableChannel(scope: realm);
|
||||
setState(() {
|
||||
_realmChannels[realm] = _sortChannels(List.from(resp));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadAllChannels() async {
|
||||
final RealmProvider realms = Get.find();
|
||||
Future.wait([
|
||||
_loadNormalChannels(),
|
||||
_loadDirectChannels(),
|
||||
...realms.availableRealms.map((x) => _loadRealmChannels(x.alias)),
|
||||
]);
|
||||
}
|
||||
|
||||
Map<int, LocalMessageEventTableData>? _lastMessages;
|
||||
|
||||
Future<void> _loadLastMessages() async {
|
||||
final ctrl = ChatEventController();
|
||||
await ctrl.initialize();
|
||||
final messages = await ctrl.src.getLastInAllChannels();
|
||||
setState(() {
|
||||
_lastMessages = messages
|
||||
.map((k, v) => MapEntry(k, v.firstOrNull))
|
||||
.cast<int, LocalMessageEventTableData>();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
try {
|
||||
_channels = Get.find();
|
||||
_channels.refreshAvailableChannel();
|
||||
} catch (e) {
|
||||
context.showErrorDialog(e);
|
||||
}
|
||||
_loadLastMessages().then((_) {
|
||||
_loadAllChannels();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AuthProvider auth = Get.find();
|
||||
final RealmProvider realms = Get.find();
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
title: AppBarTitle('chat'.tr),
|
||||
centerTitle: true,
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
actions: [
|
||||
const BackgroundStateWidget(),
|
||||
const NotificationButton(),
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Icons.add_circle),
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text('channelOrganizeCommon'.tr),
|
||||
leading: const Icon(Icons.tag),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onTap: () {
|
||||
AppRouter.instance.pushNamed('channelOrganizing').then(
|
||||
(value) {
|
||||
if (value != null) {
|
||||
_channels.refreshAvailableChannel();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text('channelOrganizeDirect'.tr),
|
||||
leading: const FaIcon(
|
||||
FontAwesomeIcons.userGroup,
|
||||
size: 16,
|
||||
return Obx(
|
||||
() => DefaultTabController(
|
||||
length: 2 + realms.availableRealms.length,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: Obx(() {
|
||||
final adaptive = AppBarLeadingButton.adaptive(context);
|
||||
if (adaptive != null) return adaptive;
|
||||
if (_channels.isLoading.value) {
|
||||
return const CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
).paddingAll(18);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}),
|
||||
title: AppBarTitle('chat'.tr),
|
||||
centerTitle: true,
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
actions: [
|
||||
const BackgroundStateWidget(),
|
||||
const NotificationButton(),
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Icons.add_circle),
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text('channelOrganizeCommon'.tr),
|
||||
leading: const Icon(Icons.tag),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
onTap: () {
|
||||
AppRouter.instance.pushNamed('channelOrganizing').then(
|
||||
(value) {
|
||||
if (value != null) {
|
||||
_loadAllChannels();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
final ChannelProvider channels = Get.find();
|
||||
channels
|
||||
.createDirectChannel(context, 'global')
|
||||
.then((resp) {
|
||||
if (resp != null) {
|
||||
_channels.refreshAvailableChannel();
|
||||
}
|
||||
}).catchError((e) {
|
||||
context.showErrorDialog(e);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: AppTheme.isLargeScreen(context) ? 8 : 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Obx(() {
|
||||
if (auth.isAuthorized.isFalse) {
|
||||
return SigninRequiredOverlay(
|
||||
onDone: () => _channels.refreshAvailableChannel(),
|
||||
);
|
||||
}
|
||||
|
||||
final selfId = auth.userProfile.value!['id'];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Obx(() {
|
||||
if (_channels.isLoading.isFalse) {
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
return const LinearProgressIndicator();
|
||||
}
|
||||
}),
|
||||
const ChatCallCurrentIndicator(),
|
||||
Expanded(
|
||||
child: CenteredContainer(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _channels.refreshAvailableChannel,
|
||||
child: Obx(
|
||||
() => ChannelListWidget(
|
||||
noCategory: true,
|
||||
channels: List.from([
|
||||
..._channels.groupChannels
|
||||
.where((x) => x.realmId == null),
|
||||
..._channels.directChannels
|
||||
]),
|
||||
selfId: selfId,
|
||||
useReplace: false,
|
||||
PopupMenuItem(
|
||||
child: ListTile(
|
||||
title: Text('channelOrganizeDirect'.tr),
|
||||
leading: const FaIcon(
|
||||
FontAwesomeIcons.userGroup,
|
||||
size: 16,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
),
|
||||
onTap: () {
|
||||
final ChannelProvider channels = Get.find();
|
||||
channels
|
||||
.createDirectChannel(context, 'global')
|
||||
.then((resp) {
|
||||
if (resp != null) {
|
||||
_loadAllChannels();
|
||||
}
|
||||
}).catchError((e) {
|
||||
context.showErrorDialog(e);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(
|
||||
width: AppTheme.isLargeScreen(context) ? 8 : 16,
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
bottom: TabBar(
|
||||
isScrollable: true,
|
||||
dividerHeight: 0.3,
|
||||
tabAlignment: TabAlignment.startOffset,
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: const Icon(
|
||||
Icons.forum,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text('all'.tr),
|
||||
],
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 14,
|
||||
child: Icon(
|
||||
Icons.chat_bubble,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text('channelTypeDirect'.tr),
|
||||
],
|
||||
),
|
||||
),
|
||||
...realms.availableRealms.map((x) => Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AccountAvatar(
|
||||
content: x.avatar,
|
||||
radius: 14,
|
||||
fallbackWidget: const Icon(
|
||||
Icons.workspaces,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(x.name),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Obx(() {
|
||||
if (auth.isAuthorized.isFalse) {
|
||||
return SigninRequiredOverlay(
|
||||
onDone: () => _loadAllChannels(),
|
||||
);
|
||||
}
|
||||
|
||||
final selfId = auth.userProfile.value!['id'];
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const ChatCallCurrentIndicator(),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
RefreshIndicator(
|
||||
onRefresh: _loadNormalChannels,
|
||||
child: ChannelListWidget(
|
||||
channels: _sortChannels([
|
||||
..._normalChannels,
|
||||
..._directChannels,
|
||||
..._realmChannels.values.expand((x) => x),
|
||||
]),
|
||||
selfId: selfId,
|
||||
useReplace: AppTheme.isLargeScreen(context),
|
||||
),
|
||||
),
|
||||
RefreshIndicator(
|
||||
onRefresh: _loadDirectChannels,
|
||||
child: ChannelListWidget(
|
||||
channels: _directChannels,
|
||||
selfId: selfId,
|
||||
useReplace: AppTheme.isLargeScreen(context),
|
||||
),
|
||||
),
|
||||
...realms.availableRealms.map(
|
||||
(x) => RefreshIndicator(
|
||||
onRefresh: () => _loadRealmChannels(x.alias),
|
||||
child: ChannelListWidget(
|
||||
channels: _realmChannels[x.alias] ?? [],
|
||||
selfId: selfId,
|
||||
useReplace: AppTheme.isLargeScreen(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import 'package:solian/router.dart';
|
||||
import 'package:solian/screens/account/notification.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/account/signin_required_overlay.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/current_state_action.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/navigation/realm_switcher.dart';
|
||||
import 'package:solian/widgets/posts/post_shuffle_swiper.dart';
|
||||
import 'package:solian/widgets/posts/post_warped_list.dart';
|
||||
|
||||
@@ -55,7 +55,6 @@ class _ExploreScreenState extends State<ExploreScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AuthProvider auth = Get.find();
|
||||
final NavigationStateProvider navState = Get.find();
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
@@ -82,8 +81,14 @@ class _ExploreScreenState extends State<ExploreScreen>
|
||||
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
|
||||
return [
|
||||
SliverAppBar(
|
||||
title: AppBarTitle('explore'.tr),
|
||||
centerTitle: true,
|
||||
flexibleSpace: SizedBox(
|
||||
height: 48,
|
||||
child: const Row(
|
||||
children: [
|
||||
RealmSwitcher(),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 8),
|
||||
).paddingOnly(top: MediaQuery.of(context).padding.top),
|
||||
floating: true,
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
@@ -96,10 +101,39 @@ class _ExploreScreenState extends State<ExploreScreen>
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
dividerHeight: 0.3,
|
||||
tabAlignment: TabAlignment.fill,
|
||||
tabs: [
|
||||
Tab(text: 'postListNews'.tr),
|
||||
Tab(text: 'postListFriends'.tr),
|
||||
Tab(text: 'postListShuffle'.tr),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.feed, size: 20),
|
||||
const Gap(8),
|
||||
Text('postListNews'.tr),
|
||||
],
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.people, size: 20),
|
||||
const Gap(8),
|
||||
Text('postListFriends'.tr),
|
||||
],
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.shuffle_on_outlined, size: 20),
|
||||
const Gap(8),
|
||||
Text('postListShuffle'.tr),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -114,16 +148,6 @@ class _ExploreScreenState extends State<ExploreScreen>
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (navState.focusedRealm.value != null)
|
||||
MaterialBanner(
|
||||
leading: const Icon(Icons.layers),
|
||||
content: Text(
|
||||
'postBrowsingIn'.trParams({
|
||||
'region': '#${navState.focusedRealm.value!.alias}',
|
||||
}),
|
||||
),
|
||||
actions: const [SizedBox.shrink()],
|
||||
),
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
|
||||
@@ -376,6 +376,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: MarkdownTextContent(
|
||||
isAutoWarp: _editorController.mode.value == 0,
|
||||
content: _editorController.contentController.text,
|
||||
parentId: 'post-editor-preview',
|
||||
).paddingOnly(top: 12, right: 16),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:get/get.dart';
|
||||
@@ -8,6 +6,7 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/attachment.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/attachment.dart';
|
||||
import 'package:solian/router.dart';
|
||||
@@ -84,36 +83,42 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (auth.isAuthorized.isFalse) return;
|
||||
|
||||
XFile file;
|
||||
|
||||
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
|
||||
if (image == null) return;
|
||||
|
||||
CroppedFile? croppedFile = await ImageCropper().cropImage(
|
||||
sourcePath: image.path,
|
||||
uiSettings: [
|
||||
AndroidUiSettings(
|
||||
toolbarTitle: 'cropImage'.tr,
|
||||
toolbarColor: Theme.of(context).colorScheme.primary,
|
||||
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
IOSUiSettings(
|
||||
title: 'cropImage'.tr,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
WebUiSettings(
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (PlatformInfo.canCropImage) {
|
||||
CroppedFile? croppedFile = await ImageCropper().cropImage(
|
||||
sourcePath: image.path,
|
||||
uiSettings: [
|
||||
AndroidUiSettings(
|
||||
toolbarTitle: 'cropImage'.tr,
|
||||
toolbarColor: Theme.of(context).colorScheme.primary,
|
||||
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
IOSUiSettings(
|
||||
title: 'cropImage'.tr,
|
||||
aspectRatioPresets: [
|
||||
if (position == 'avatar') CropAspectRatioPreset.square,
|
||||
if (position == 'banner') _BannerCropAspectRatioPreset(),
|
||||
],
|
||||
),
|
||||
WebUiSettings(
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (croppedFile == null) return;
|
||||
final file = File(croppedFile.path);
|
||||
if (croppedFile == null) return;
|
||||
file = XFile(croppedFile.path);
|
||||
} else {
|
||||
file = XFile(image.path);
|
||||
}
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
|
||||
@@ -68,12 +68,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
|
||||
_channels.addAll(
|
||||
resp.body.map((e) => Channel.fromJson(e)).toList().cast<Channel>(),
|
||||
);
|
||||
_channels.addAll(
|
||||
availableResp.body
|
||||
.map((e) => Channel.fromJson(e))
|
||||
.toList()
|
||||
.cast<Channel>(),
|
||||
);
|
||||
_channels.addAll(availableResp);
|
||||
_channels.retainWhere((x) => channelIdx.add(x.id));
|
||||
});
|
||||
|
||||
@@ -260,7 +255,6 @@ class RealmChannelListWidget extends StatelessWidget {
|
||||
child: ChannelListWidget(
|
||||
channels: channels,
|
||||
selfId: auth.userProfile.value!['id'],
|
||||
noCategory: true,
|
||||
),
|
||||
)
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:solian/theme.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>();
|
||||
|
||||
@@ -40,8 +41,11 @@ class RootShell extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final showRailNavigation = AppTheme.isLargeScreen(context);
|
||||
|
||||
final destNames = AppNavigation.destinations.map((x) => x.page).toList();
|
||||
final showBottomNavigation = destNames.contains(routeName);
|
||||
final showBottomNavigation =
|
||||
destNames.contains(routeName) && !showRailNavigation;
|
||||
|
||||
return Scaffold(
|
||||
key: rootScaffoldKey,
|
||||
@@ -53,6 +57,12 @@ class RootShell extends StatelessWidget {
|
||||
body: AppTheme.isLargeScreen(context)
|
||||
? Row(
|
||||
children: [
|
||||
if (showRailNavigation) const AppNavigationRail(),
|
||||
if (showRailNavigation)
|
||||
const VerticalDivider(
|
||||
width: 0.3,
|
||||
thickness: 0.3,
|
||||
),
|
||||
Expanded(child: child),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/app_bar_leading.dart';
|
||||
import 'package:solian/widgets/app_bar_title.dart';
|
||||
import 'package:solian/widgets/sidebar/sidebar_placeholder.dart';
|
||||
|
||||
class SidebarShell extends StatelessWidget {
|
||||
final bool showAppBar;
|
||||
final GoRouterState state;
|
||||
final Widget child;
|
||||
|
||||
final bool sidebarFirst;
|
||||
final Widget? sidebar;
|
||||
|
||||
const SidebarShell({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.state,
|
||||
this.showAppBar = true,
|
||||
this.sidebarFirst = false,
|
||||
this.sidebar,
|
||||
});
|
||||
|
||||
List<Widget> buildContent(BuildContext context) {
|
||||
return [
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: child,
|
||||
),
|
||||
if (AppTheme.isExtraLargeScreen(context))
|
||||
const VerticalDivider(thickness: 0.3, width: 1),
|
||||
if (AppTheme.isExtraLargeScreen(context))
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: sidebar ?? const SidebarPlaceholder(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: showAppBar
|
||||
? AppBar(
|
||||
leading: AppBarLeadingButton.adaptive(context),
|
||||
title: AppBarTitle(state.topRoute?.name?.tr ?? 'page'.tr),
|
||||
centerTitle: false,
|
||||
toolbarHeight: AppTheme.toolbarHeight(context),
|
||||
)
|
||||
: null,
|
||||
body: AppTheme.isLargeScreen(context)
|
||||
? Row(
|
||||
children: sidebarFirst
|
||||
? buildContent(context).reversed.toList()
|
||||
: buildContent(context),
|
||||
)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,10 @@ abstract class AppTheme {
|
||||
MediaQuery.of(context).size.width > 640;
|
||||
|
||||
static bool isExtraLargeScreen(BuildContext context) =>
|
||||
MediaQuery.of(context).size.width > 720;
|
||||
MediaQuery.of(context).size.width > 920;
|
||||
|
||||
static bool isUltraLargeScreen(BuildContext context) =>
|
||||
MediaQuery.of(context).size.width > 1200;
|
||||
|
||||
static bool isSpecializedMacOS(BuildContext context) =>
|
||||
PlatformInfo.isMacOS && !AppTheme.isLargeScreen(context);
|
||||
|
||||
@@ -7,6 +7,7 @@ class AccountAvatar extends StatelessWidget {
|
||||
final Color? bgColor;
|
||||
final Color? feColor;
|
||||
final double? radius;
|
||||
final Widget? fallbackWidget;
|
||||
|
||||
const AccountAvatar({
|
||||
super.key,
|
||||
@@ -14,6 +15,7 @@ class AccountAvatar extends StatelessWidget {
|
||||
this.bgColor,
|
||||
this.feColor,
|
||||
this.radius,
|
||||
this.fallbackWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -35,11 +37,12 @@ class AccountAvatar extends StatelessWidget {
|
||||
backgroundColor: bgColor,
|
||||
backgroundImage: !isEmpty ? AutoCacheImage.provider(url) : null,
|
||||
child: isEmpty
|
||||
? Icon(
|
||||
Icons.account_circle,
|
||||
size: radius != null ? radius! * 1.2 : 24,
|
||||
color: feColor,
|
||||
)
|
||||
? (fallbackWidget ??
|
||||
Icon(
|
||||
Icons.account_circle,
|
||||
size: radius != null ? radius! * 1.2 : 24,
|
||||
color: feColor,
|
||||
))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,7 +396,8 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
|
||||
),
|
||||
if (!element.isCompleted &&
|
||||
element.error == null &&
|
||||
canBeCrop)
|
||||
canBeCrop &&
|
||||
PlatformInfo.canCropImage)
|
||||
Obx(
|
||||
() => IconButton(
|
||||
color: Colors.teal,
|
||||
|
||||
@@ -98,12 +98,12 @@ class ChannelCallIndicator extends StatelessWidget {
|
||||
child: Text('callJoin'.tr),
|
||||
);
|
||||
} else if (call.channel.value?.id == channel.id &&
|
||||
!AppTheme.isLargeScreen(context)) {
|
||||
!AppTheme.isUltraLargeScreen(context)) {
|
||||
return TextButton(
|
||||
onPressed: () => onJoin(),
|
||||
child: Text('callResume'.tr),
|
||||
);
|
||||
} else if (!AppTheme.isLargeScreen(context)) {
|
||||
} else if (!AppTheme.isUltraLargeScreen(context)) {
|
||||
return TextButton(
|
||||
onPressed: null,
|
||||
child: Text('callJoin'.tr),
|
||||
|
||||
@@ -4,18 +4,18 @@ import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:solian/controllers/chat_events_controller.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/platform.dart';
|
||||
import 'package:solian/providers/database/database.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:badges/badges.dart' as badges;
|
||||
|
||||
class ChannelListWidget extends StatefulWidget {
|
||||
final List<Channel> channels;
|
||||
final int selfId;
|
||||
final bool isDense;
|
||||
final bool isCollapsed;
|
||||
final bool noCategory;
|
||||
final bool useReplace;
|
||||
final Function(Channel)? onSelected;
|
||||
|
||||
@@ -23,9 +23,6 @@ class ChannelListWidget extends StatefulWidget {
|
||||
super.key,
|
||||
required this.channels,
|
||||
required this.selfId,
|
||||
this.isDense = false,
|
||||
this.isCollapsed = false,
|
||||
this.noCategory = false,
|
||||
this.useReplace = false,
|
||||
this.onSelected,
|
||||
});
|
||||
@@ -35,43 +32,25 @@ class ChannelListWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ChannelListWidgetState extends State<ChannelListWidget> {
|
||||
final List<Channel> _globalChannels = List.empty(growable: true);
|
||||
final Map<String, List<Channel>> _inRealms = {};
|
||||
Map<int, LocalMessageEventTableData>? _lastMessages;
|
||||
|
||||
final ChatEventController _eventController = ChatEventController();
|
||||
|
||||
void _mapChannels() {
|
||||
_inRealms.clear();
|
||||
_globalChannels.clear();
|
||||
|
||||
if (widget.noCategory) {
|
||||
_globalChannels.addAll(widget.channels);
|
||||
return;
|
||||
}
|
||||
|
||||
for (final channel in widget.channels) {
|
||||
if (channel.realmId != null) {
|
||||
if (_inRealms[channel.realm!.alias] == null) {
|
||||
_inRealms[channel.realm!.alias] = List.empty(growable: true);
|
||||
}
|
||||
_inRealms[channel.realm!.alias]!.add(channel);
|
||||
} else {
|
||||
_globalChannels.add(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ChannelListWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
setState(() => _mapChannels());
|
||||
Future<void> _loadLastMessages() async {
|
||||
final messages = await _eventController.src.getLastInAllChannels();
|
||||
setState(() {
|
||||
_lastMessages = messages
|
||||
.map((k, v) => MapEntry(k, v.firstOrNull))
|
||||
.cast<int, LocalMessageEventTableData>();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapChannels();
|
||||
_eventController.initialize();
|
||||
_eventController.initialize().then((_) {
|
||||
_loadLastMessages();
|
||||
});
|
||||
}
|
||||
|
||||
void _gotoChannel(Channel item) {
|
||||
@@ -98,39 +77,129 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDirectMessageDescription(Channel item, ChannelMember otherside) {
|
||||
Widget _buildTitle(Channel item, ChannelMember? otherside) {
|
||||
if (otherside != null) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: Text(otherside.account.nick)),
|
||||
if (_lastMessages != null && _lastMessages![item.id] != null)
|
||||
Text(
|
||||
DateFormat('MM/dd').format(
|
||||
_lastMessages![item.id]!.createdAt.toLocal(),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color:
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(child: Text(item.name)),
|
||||
if (_lastMessages != null && _lastMessages![item.id] != null)
|
||||
Text(
|
||||
DateFormat('MM/dd').format(
|
||||
_lastMessages![item.id]!.createdAt.toLocal(),
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubtitle(Channel item, ChannelMember? otherside) {
|
||||
if (PlatformInfo.isWeb) {
|
||||
return Text('channelDirectDescription'.trParams(
|
||||
{'username': '@${otherside.account.name}'},
|
||||
));
|
||||
return otherside != null
|
||||
? Text(
|
||||
'channelDirectDescription'.trParams(
|
||||
{'username': '@${otherside.account.name}'},
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text(
|
||||
item.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
|
||||
return FutureBuilder(
|
||||
future: Future.delayed(
|
||||
const Duration(milliseconds: 500),
|
||||
() => _eventController.src.getLastInChannel(item),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData && snapshot.data == null) {
|
||||
return Text('channelDirectDescription'.trParams(
|
||||
{'username': '@${otherside.account.name}'},
|
||||
));
|
||||
}
|
||||
|
||||
final data = snapshot.data!.data!;
|
||||
return Text(
|
||||
'${data.sender.account.nick}: ${data.body['text'] ?? 'Unsupported message to preview'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
return AnimatedSwitcher(
|
||||
switchInCurve: Curves.easeIn,
|
||||
switchOutCurve: Curves.easeOut,
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: (_lastMessages == null || _lastMessages![item.id] == null)
|
||||
? Builder(
|
||||
builder: (context) {
|
||||
return otherside != null
|
||||
? Text(
|
||||
'channelDirectDescription'.trParams(
|
||||
{'username': '@${otherside.account.name}'},
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text(
|
||||
item.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
)
|
||||
: Builder(
|
||||
builder: (context) {
|
||||
final data = _lastMessages![item.id]!.data!;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (item.type == 0)
|
||||
Badge(
|
||||
label: Text(data.sender.account.nick),
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
textColor:
|
||||
Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
if (item.type == 0) const Gap(6),
|
||||
if (data.body['text'] != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.body['text'],
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
else
|
||||
Badge(label: Text('unablePreview'.tr)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
layoutBuilder: (currentChild, previousChildren) {
|
||||
return Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: <Widget>[
|
||||
...previousChildren,
|
||||
if (currentChild != null) currentChild,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntry(Channel item) {
|
||||
final padding = widget.isDense
|
||||
? const EdgeInsets.symmetric(horizontal: 20)
|
||||
: const EdgeInsets.symmetric(horizontal: 16);
|
||||
const padding = EdgeInsets.symmetric(horizontal: 20);
|
||||
|
||||
final otherside =
|
||||
item.members!.where((e) => e.account.id != widget.selfId).firstOrNull;
|
||||
@@ -138,67 +207,53 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
|
||||
if (item.type == 1 && otherside != null) {
|
||||
final avatar = AccountAvatar(
|
||||
content: otherside.account.avatar,
|
||||
radius: widget.isDense ? 12 : 20,
|
||||
radius: 20,
|
||||
bgColor: Theme.of(context).colorScheme.primary,
|
||||
feColor: Theme.of(context).colorScheme.onPrimary,
|
||||
);
|
||||
|
||||
if (widget.isCollapsed) {
|
||||
return Tooltip(
|
||||
message: otherside.account.nick,
|
||||
child: InkWell(
|
||||
child: avatar.paddingSymmetric(vertical: 12),
|
||||
onTap: () => _gotoChannel(item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading: avatar,
|
||||
contentPadding: padding,
|
||||
title: Text(otherside.account.nick),
|
||||
subtitle: !widget.isDense
|
||||
? _buildDirectMessageDescription(item, otherside)
|
||||
: null,
|
||||
title: _buildTitle(item, otherside),
|
||||
subtitle: _buildSubtitle(item, otherside),
|
||||
onTap: () => _gotoChannel(item),
|
||||
);
|
||||
} else {
|
||||
final avatar = CircleAvatar(
|
||||
backgroundColor: item.realmId == null
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
radius: widget.isDense ? 12 : 20,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
radius: 20,
|
||||
child: FaIcon(
|
||||
FontAwesomeIcons.hashtag,
|
||||
color: item.realmId == null
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
size: widget.isDense ? 12 : 16,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
size: 16,
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.isCollapsed) {
|
||||
return Tooltip(
|
||||
message: item.name,
|
||||
child: InkWell(
|
||||
child: avatar.paddingSymmetric(vertical: 12),
|
||||
onTap: () => _gotoChannel(item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
minTileHeight: widget.isDense ? 48 : null,
|
||||
leading: avatar,
|
||||
minTileHeight: null,
|
||||
leading: item.realmId == null
|
||||
? avatar
|
||||
: badges.Badge(
|
||||
position: badges.BadgePosition.bottomEnd(bottom: -4, end: -6),
|
||||
badgeStyle: badges.BadgeStyle(
|
||||
badgeColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
padding: const EdgeInsets.all(2),
|
||||
elevation: 8,
|
||||
),
|
||||
badgeContent: AccountAvatar(
|
||||
content: item.realm?.avatar,
|
||||
radius: 10,
|
||||
fallbackWidget: const Icon(
|
||||
Icons.workspaces,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
child: avatar,
|
||||
),
|
||||
contentPadding: padding,
|
||||
title: Text(item.name),
|
||||
subtitle: !widget.isDense
|
||||
? Text(
|
||||
item.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: null,
|
||||
title: _buildTitle(item, null),
|
||||
subtitle: _buildSubtitle(item, null),
|
||||
onTap: () => _gotoChannel(item),
|
||||
);
|
||||
}
|
||||
@@ -206,49 +261,16 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.noCategory) {
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverList.builder(
|
||||
itemCount: _globalChannels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final element = _globalChannels[index];
|
||||
return _buildEntry(element);
|
||||
},
|
||||
),
|
||||
SliverGap(max(16, MediaQuery.of(context).padding.bottom)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverList.builder(
|
||||
itemCount: _globalChannels.length,
|
||||
itemCount: widget.channels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final element = _globalChannels[index];
|
||||
final element = widget.channels[index];
|
||||
return _buildEntry(element);
|
||||
},
|
||||
),
|
||||
SliverList.list(
|
||||
children: _inRealms.entries.map((element) {
|
||||
return ExpansionTile(
|
||||
tilePadding: const EdgeInsets.only(left: 20, right: 24),
|
||||
minTileHeight: 48,
|
||||
title: Text(element.value.first.realm!.name),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.teal,
|
||||
radius: widget.isDense ? 12 : 24,
|
||||
child: Icon(
|
||||
Icons.workspaces,
|
||||
color: Colors.white,
|
||||
size: widget.isDense ? 12 : 16,
|
||||
),
|
||||
),
|
||||
children: element.value.map((x) => _buildEntry(x)).toList(),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
SliverGap(max(16, MediaQuery.of(context).padding.bottom)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ abstract class AppNavigation {
|
||||
static List<AppNavigationDestination> destinations = [
|
||||
AppNavigationDestination(
|
||||
icon: const Icon(Icons.dashboard),
|
||||
label: 'dashboard'.tr,
|
||||
label: 'dashboardNav'.tr,
|
||||
page: 'dashboard',
|
||||
),
|
||||
AppNavigationDestination(
|
||||
@@ -14,19 +14,19 @@ abstract class AppNavigation {
|
||||
label: 'explore'.tr,
|
||||
page: 'explore',
|
||||
),
|
||||
AppNavigationDestination(
|
||||
icon: const Icon(Icons.workspaces),
|
||||
label: 'realms'.tr,
|
||||
page: 'realms',
|
||||
),
|
||||
AppNavigationDestination(
|
||||
icon: const Icon(Icons.forum),
|
||||
label: 'chat'.tr,
|
||||
page: 'chat',
|
||||
),
|
||||
AppNavigationDestination(
|
||||
icon: const Icon(Icons.workspaces),
|
||||
label: 'realms'.tr,
|
||||
page: 'realms',
|
||||
),
|
||||
AppNavigationDestination(
|
||||
icon: const AppAccountWidget(),
|
||||
label: 'account'.tr,
|
||||
label: 'accountNav'.tr,
|
||||
page: 'account',
|
||||
),
|
||||
];
|
||||
|
||||
@@ -28,7 +28,7 @@ class _AppNavigationBottomState extends State<AppNavigationBottom> {
|
||||
currentIndex: _currentIndex,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
showUnselectedLabels: false,
|
||||
showSelectedLabels: false,
|
||||
showSelectedLabels: true,
|
||||
landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
|
||||
items: AppNavigation.destinations
|
||||
.map(
|
||||
|
||||
65
lib/widgets/navigation/app_navigation_rail.dart
Normal file
65
lib/widgets/navigation/app_navigation_rail.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/widgets/navigation/app_navigation.dart';
|
||||
|
||||
class AppNavigationRail extends StatefulWidget {
|
||||
final int initialIndex;
|
||||
|
||||
const AppNavigationRail({super.key, this.initialIndex = 0});
|
||||
|
||||
@override
|
||||
State<AppNavigationRail> createState() => _AppNavigationRailState();
|
||||
}
|
||||
|
||||
class _AppNavigationRailState extends State<AppNavigationRail> {
|
||||
int? _currentIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialIndex >= 0) {
|
||||
_currentIndex = widget.initialIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NavigationRail(
|
||||
selectedIndex: _currentIndex,
|
||||
labelType: NavigationRailLabelType.selected,
|
||||
groupAlignment: -1,
|
||||
destinations: AppNavigation.destinations
|
||||
.sublist(0, AppNavigation.destinations.length - 1)
|
||||
.map(
|
||||
(x) => NavigationRailDestination(
|
||||
icon: x.icon,
|
||||
label: Text(x.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
trailing: Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: IconButton(
|
||||
icon: AppNavigation.destinations.last.icon,
|
||||
tooltip: AppNavigation.destinations.last.label,
|
||||
onPressed: () {
|
||||
setState(() => _currentIndex = null);
|
||||
AppRouter.instance.goNamed(AppNavigation.destinations.last.page);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
onDestinationSelected: (idx) {
|
||||
setState(() => _currentIndex = idx);
|
||||
AppRouter.instance.goNamed(AppNavigation.destinations[idx].page);
|
||||
},
|
||||
).paddingOnly(
|
||||
top: max(16, MediaQuery.of(context).padding.top),
|
||||
bottom: max(16, MediaQuery.of(context).padding.bottom),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/content/channel.dart';
|
||||
import 'package:solian/providers/content/realm.dart';
|
||||
import 'package:solian/providers/navigation.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/auto_cache_image.dart';
|
||||
import 'package:solian/widgets/channel/channel_list.dart';
|
||||
|
||||
class AppNavigationRegion extends StatefulWidget {
|
||||
final bool isCollapsed;
|
||||
final Function onSelected;
|
||||
|
||||
const AppNavigationRegion({
|
||||
super.key,
|
||||
this.isCollapsed = false,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AppNavigationRegion> createState() => _AppNavigationRegionState();
|
||||
}
|
||||
|
||||
class _AppNavigationRegionState extends State<AppNavigationRegion> {
|
||||
bool _isTryingExit = false;
|
||||
|
||||
void _focusRealm(Realm item) {
|
||||
setState(
|
||||
() => Get.find<NavigationStateProvider>().focusedRealm.value = item,
|
||||
);
|
||||
}
|
||||
|
||||
void _unFocusRealm() {
|
||||
setState(
|
||||
() => Get.find<NavigationStateProvider>().focusedRealm.value = null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildRealmFocusAvatar() {
|
||||
final focusedRealm = Get.find<NavigationStateProvider>().focusedRealm.value;
|
||||
return GestureDetector(
|
||||
child: MouseRegion(
|
||||
child: AnimatedSwitcher(
|
||||
switchInCurve: Curves.fastOutSlowIn,
|
||||
switchOutCurve: Curves.fastOutSlowIn,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transitionBuilder: (child, animation) {
|
||||
return ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: _isTryingExit
|
||||
? CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: const Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
).paddingSymmetric(
|
||||
vertical: 8,
|
||||
)
|
||||
: _buildEntryAvatar(focusedRealm!),
|
||||
),
|
||||
onEnter: (_) => setState(() => _isTryingExit = true),
|
||||
onExit: (_) => setState(() => _isTryingExit = false),
|
||||
),
|
||||
onTap: () => _unFocusRealm(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntryAvatar(Realm item) {
|
||||
return Hero(
|
||||
tag: Key('region-realm-avatar-${item.id}'),
|
||||
child: (item.avatar?.isNotEmpty ?? false)
|
||||
? AccountAvatar(content: item.avatar)
|
||||
: CircleAvatar(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: const Icon(
|
||||
Icons.workspaces,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
).paddingSymmetric(
|
||||
vertical: 8,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntry(BuildContext context, Realm item) {
|
||||
const padding = EdgeInsets.symmetric(horizontal: 20, vertical: 8);
|
||||
|
||||
if (widget.isCollapsed) {
|
||||
return InkWell(
|
||||
child: _buildEntryAvatar(item).paddingSymmetric(vertical: 8),
|
||||
onTap: () => _focusRealm(item),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
minTileHeight: 0,
|
||||
leading: _buildEntryAvatar(item),
|
||||
contentPadding: padding,
|
||||
title: Text(item.name),
|
||||
subtitle: Text(
|
||||
item.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () => _focusRealm(item),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final RealmProvider realms = Get.find();
|
||||
final ChannelProvider channels = Get.find();
|
||||
final AuthProvider auth = Get.find();
|
||||
final NavigationStateProvider navState = Get.find();
|
||||
|
||||
return Obx(
|
||||
() => PageTransitionSwitcher(
|
||||
transitionBuilder: (child, animation, secondaryAnimation) {
|
||||
return SharedAxisTransition(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: navState.focusedRealm.value == null
|
||||
? widget.isCollapsed
|
||||
? CustomScrollView(
|
||||
slivers: [
|
||||
const SliverPadding(padding: EdgeInsets.only(top: 16)),
|
||||
SliverList.builder(
|
||||
itemCount: realms.availableRealms.length,
|
||||
itemBuilder: (context, index) {
|
||||
final element = realms.availableRealms[index];
|
||||
return Tooltip(
|
||||
message: element.name,
|
||||
child: _buildEntry(context, element),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: CustomScrollView(
|
||||
slivers: [
|
||||
SliverList.builder(
|
||||
itemCount: realms.availableRealms.length,
|
||||
itemBuilder: (context, index) {
|
||||
final element = realms.availableRealms[index];
|
||||
return _buildEntry(context, element);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
if (!widget.isCollapsed &&
|
||||
(navState.focusedRealm.value!.banner?.isNotEmpty ??
|
||||
false))
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 7,
|
||||
child: AutoCacheImage(
|
||||
ServiceFinder.buildUrl(
|
||||
'uc',
|
||||
'/attachments/${navState.focusedRealm.value!.banner}',
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
if (widget.isCollapsed)
|
||||
Tooltip(
|
||||
message: navState.focusedRealm.value!.name,
|
||||
child: _buildRealmFocusAvatar().paddingOnly(
|
||||
top: 24,
|
||||
bottom: 8,
|
||||
),
|
||||
)
|
||||
else
|
||||
ListTile(
|
||||
minTileHeight: 0,
|
||||
tileColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
leading: _buildRealmFocusAvatar(),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20, vertical: 8),
|
||||
title: Text(navState.focusedRealm.value!.name),
|
||||
subtitle: Text(
|
||||
navState.focusedRealm.value!.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Obx(
|
||||
() => ChannelListWidget(
|
||||
useReplace: true,
|
||||
channels: channels.availableChannels
|
||||
.where((x) =>
|
||||
x.realm?.id == navState.focusedRealm.value?.id)
|
||||
.toList(),
|
||||
isCollapsed: widget.isCollapsed,
|
||||
selfId: auth.userProfile.value!['id'],
|
||||
noCategory: true,
|
||||
onSelected: (_) => widget.onSelected(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
92
lib/widgets/navigation/realm_switcher.dart
Normal file
92
lib/widgets/navigation/realm_switcher.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/providers/content/realm.dart';
|
||||
import 'package:solian/providers/navigation.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
|
||||
class RealmSwitcher extends StatelessWidget {
|
||||
const RealmSwitcher({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final realms = Get.find<RealmProvider>();
|
||||
final navState = Get.find<NavigationStateProvider>();
|
||||
|
||||
return Obx(() {
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<Realm?>(
|
||||
iconStyleData: const IconStyleData(iconSize: 0),
|
||||
isExpanded: true,
|
||||
hint: Text(
|
||||
'Realm Region',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
items: [null, ...realms.availableRealms]
|
||||
.map((Realm? item) => DropdownMenuItem<Realm?>(
|
||||
value: item,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (item != null)
|
||||
AccountAvatar(
|
||||
content: item.avatar,
|
||||
radius: 14,
|
||||
fallbackWidget: const Icon(
|
||||
Icons.workspaces,
|
||||
size: 16,
|
||||
),
|
||||
)
|
||||
else
|
||||
CircleAvatar(
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
radius: 14,
|
||||
child: const Icon(
|
||||
Icons.public,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item?.name ?? 'global'.tr,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
value: navState.focusedRealm.value,
|
||||
onChanged: (Realm? value) {
|
||||
navState.focusedRealm.value = value;
|
||||
},
|
||||
buttonStyleData: ButtonStyleData(
|
||||
height: 48,
|
||||
width: max(200, MediaQuery.of(context).size.width * 0.4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
),
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 48,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class _PostEditorThumbnailDialogState extends State<PostEditorThumbnailDialog> {
|
||||
_attachmentController.text = value.toString();
|
||||
});
|
||||
|
||||
widget.controller.thumbnail.value = value;
|
||||
widget.controller.thumbnail.value = value.isEmpty ? null : value;
|
||||
},
|
||||
initialAttachments: const [],
|
||||
onRemove: (_) {},
|
||||
@@ -91,7 +91,8 @@ class _PostEditorThumbnailDialogState extends State<PostEditorThumbnailDialog> {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.controller.thumbnail.value = _attachmentController.text;
|
||||
final text = _attachmentController.text;
|
||||
widget.controller.thumbnail.value = text.isEmpty ? null : text;
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('confirm'.tr),
|
||||
|
||||
@@ -8,7 +8,10 @@ class EmptyPagePlaceholder extends StatelessWidget {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Center(
|
||||
child: Image.asset('assets/logo.png', width: 80, height: 80),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
child: Image.asset('assets/logo.png', width: 80, height: 80),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SidebarPlaceholder extends StatelessWidget {
|
||||
const SidebarPlaceholder({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: const Center(
|
||||
child: Icon(Icons.menu_open, size: 50),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -57,5 +57,11 @@
|
||||
<string>INStartCallIntent</string>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Allow you take photo/video for your message or post</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Allow you record audio for your message or post</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Allow you add photo to your message or post</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -2,7 +2,7 @@ name: solian
|
||||
description: "The Solar Network App"
|
||||
publish_to: "none"
|
||||
|
||||
version: 1.2.5+1
|
||||
version: 1.3.6+2
|
||||
|
||||
environment:
|
||||
sdk: ">=3.3.4 <4.0.0"
|
||||
|
||||
Reference in New Issue
Block a user