Compare commits

...

10 Commits

Author SHA1 Message Date
b6ebd6bef6 🐛 Drawer will expand on mobile device 2024-08-21 20:55:22 +08:00
2ec25fd1a2 Drawer tooltip on collapse mode 2024-08-21 19:35:29 +08:00
bc99865ba8 💫 Animated collapsible sidebar 2024-08-21 19:11:27 +08:00
f834351ce2 Basis collapse sidebar 2024-08-21 17:00:59 +08:00
0f1a02f65b 🐛 Try to fix protocol handler issue on android 2024-08-21 16:02:00 +08:00
6ad0a34645 Call on large screen able to full screen 2024-08-21 15:57:45 +08:00
fdc71475fc 💄 Optimize message hint 2024-08-21 15:45:55 +08:00
047defebd1 🥅 Better request failed exceptions 2024-08-21 15:39:29 +08:00
6148e889aa 🥅 Better unauthorized exceptions 2024-08-21 15:25:50 +08:00
1d7affcd84 🐛 Bug fixes 2024-08-21 13:14:40 +08:00
27 changed files with 585 additions and 305 deletions

View File

@ -1,17 +1,17 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera"/> <uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus"/> <uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@ -19,31 +19,31 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<application <application
android:label="Solian" android:label="Solian"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:supportsRtl="true"> android:supportsRtl="true">
<receiver android:exported="false" <receiver android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver"/> android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
<receiver android:exported="false" <receiver android:exported="false"
android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver"> android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationBootReceiver">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/> <action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/> <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON"/> <action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues while the Flutter UI initializes. After that, this theme continues
@ -58,29 +58,36 @@
<data android:host="sn.solsynth.dev" /> <data android:host="sn.solsynth.dev" />
<data android:scheme="https" /> <data android:scheme="https" />
<data android:scheme="https" /> <data android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="solink" /> <data android:scheme="solink" />
</intent-filter> </intent-filter>
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
/> />
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity <activity
android:name="com.yalantis.ucrop.UCropActivity" android:name="com.yalantis.ucrop.UCropActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"/> android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2"/> android:value="2" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and
@ -89,8 +96,8 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@ -112,15 +112,19 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
label: 'bsPreparingData', label: 'bsPreparingData',
action: () async { action: () async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
await Future.wait([ try {
Get.find<StickerProvider>().refreshAvailableStickers(), await Future.wait([
if (auth.isAuthorized.isTrue) Get.find<StickerProvider>().refreshAvailableStickers(),
Get.find<ChannelProvider>().refreshAvailableChannel(), if (auth.isAuthorized.isTrue)
if (auth.isAuthorized.isTrue) Get.find<ChannelProvider>().refreshAvailableChannel(),
Get.find<RelationshipProvider>().refreshRelativeList(), if (auth.isAuthorized.isTrue)
if (auth.isAuthorized.isTrue) Get.find<RelationshipProvider>().refreshRelativeList(),
Get.find<RealmProvider>().refreshAvailableRealms(), if (auth.isAuthorized.isTrue)
]); Get.find<RealmProvider>().refreshAvailableRealms(),
]);
} catch (e) {
context.showErrorDialog(e);
}
}, },
), ),
( (

View File

@ -0,0 +1,10 @@
import 'package:get/get.dart';
class RequestException implements Exception {
final Response data;
const RequestException(this.data);
@override
String toString() => 'Request failed ${data.statusCode}: ${data.bodyString}';
}

View File

@ -0,0 +1,6 @@
class UnauthorizedException implements Exception {
const UnauthorizedException();
@override
String toString() => 'Unauthorized';
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
extension SolianExtenions on BuildContext { extension SolianExtenions on BuildContext {
void showSnackbar(String content, {SnackBarAction? action}) { void showSnackbar(String content, {SnackBarAction? action}) {
@ -48,15 +50,48 @@ extension SolianExtenions on BuildContext {
} }
Future<void> showErrorDialog(dynamic exception) { Future<void> showErrorDialog(dynamic exception) {
var stack = StackTrace.current; Widget content = Text(exception.toString().capitalize!);
var stackTrace = '$stack'; if (exception is UnauthorizedException) {
content = Text('errorHappenedUnauthorized'.tr);
}
if (exception is RequestException) {
String overall;
switch (exception.data.statusCode) {
case 400:
overall = 'errorHappenedRequestBad'.tr;
break;
case 401:
overall = 'errorHappenedUnauthorized'.tr;
break;
case 403:
overall = 'errorHappenedRequestForbidden'.tr;
break;
case 404:
overall = 'errorHappenedRequestNotFound'.tr;
break;
case null:
overall = 'errorHappenedRequestConnection'.tr;
break;
default:
overall = 'errorHappenedRequestUnknown'.tr;
break;
}
if (exception.data.statusCode != null) {
content = Text(
'$overall\n\n(${exception.data.statusCode}) ${exception.data.bodyString}',
);
} else {
content = Text(overall);
}
}
return showDialog<void>( return showDialog<void>(
useRootNavigator: true, useRootNavigator: true,
context: this, context: this,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: Text('errorHappened'.tr), title: Text('errorHappened'.tr),
content: Text('${exception.toString().capitalize!}\n\nStack Trace: $stackTrace'), content: content,
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx), onPressed: () => Navigator.pop(ctx),

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/account_status.dart'; import 'package:solian/models/account_status.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@ -33,15 +35,14 @@ class StatusProvider extends GetConnect {
Future<Response> getCurrentStatus() async { Future<Response> getCurrentStatus() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
return await client.get('/users/me/status'); return await client.get('/users/me/status');
} }
Future<Response> getSomeoneStatus(String name) => Future<Response> getSomeoneStatus(String name) => get('/users/$name/status');
get('/users/$name/status');
Future<Response> setStatus( Future<Response> setStatus(
String type, String type,
@ -53,7 +54,7 @@ class StatusProvider extends GetConnect {
DateTime? clearAt, DateTime? clearAt,
}) async { }) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
@ -74,7 +75,7 @@ class StatusProvider extends GetConnect {
} }
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -82,13 +83,13 @@ class StatusProvider extends GetConnect {
Future<Response> clearStatus() async { Future<Response> clearStatus() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
final resp = await client.delete('/users/me/status'); final resp = await client.delete('/users/me/status');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;

View File

@ -7,6 +7,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get/get_connect/http/src/request/request.dart'; import 'package:get/get_connect/http/src/request/request.dart';
import 'package:solian/controllers/chat_events_controller.dart'; import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/providers/websocket.dart'; import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@ -81,7 +83,7 @@ class AuthProvider extends GetConnect {
'grant_type': 'refresh_token', 'grant_type': 'refresh_token',
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
credentials = TokenSet( credentials = TokenSet(
accessToken: resp.body['access_token'], accessToken: resp.body['access_token'],
@ -128,7 +130,7 @@ class AuthProvider extends GetConnect {
} }
Future<void> ensureCredentials() async { Future<void> ensureCredentials() async {
if (isAuthorized.isFalse) throw Exception('unauthorized'); if (isAuthorized.isFalse) throw const UnauthorizedException();
if (credentials == null) await loadCredentials(); if (credentials == null) await loadCredentials();
if (credentials!.isExpired) { if (credentials!.isExpired) {
@ -158,7 +160,7 @@ class AuthProvider extends GetConnect {
'password': password, 'password': password,
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} else if (resp.body['is_finished'] == false) { } else if (resp.body['is_finished'] == false) {
throw RiskyAuthenticateException(resp.body['ticket']['id']); throw RiskyAuthenticateException(resp.body['ticket']['id']);
} }
@ -218,7 +220,7 @@ class AuthProvider extends GetConnect {
final client = configureClient('auth'); final client = configureClient('auth');
final resp = await client.get('/users/me'); final resp = await client.get('/users/me');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
userProfile.value = resp.body; userProfile.value = resp.body;

View File

@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:solian/models/call.dart'; import 'package:solian/models/call.dart';
@ -88,7 +90,7 @@ class ChatCallProvider extends GetxController {
Future<(String, String)> getRoomToken() async { Future<(String, String)> getRoomToken() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
@ -101,7 +103,7 @@ class ChatCallProvider extends GetxController {
endpoint = 'wss://${resp.body['endpoint']}'; endpoint = 'wss://${resp.body['endpoint']}';
return (token!, endpoint!); return (token!, endpoint!);
} else { } else {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
} }

View File

@ -2,6 +2,8 @@ import 'dart:convert';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:solian/models/attachment.dart'; import 'package:solian/models/attachment.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
@ -89,7 +91,7 @@ class AttachmentProvider extends GetConnect {
Map<String, dynamic>? metadata, Map<String, dynamic>? metadata,
) async { ) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient( final client = auth.configureClient(
'uc', 'uc',
@ -118,7 +120,7 @@ class AttachmentProvider extends GetConnect {
}); });
final resp = await client.post('/attachments', payload); final resp = await client.post('/attachments', payload);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return Attachment.fromJson(resp.body); return Attachment.fromJson(resp.body);
@ -131,7 +133,7 @@ class AttachmentProvider extends GetConnect {
Map<String, dynamic>? metadata, Map<String, dynamic>? metadata,
) async { ) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('uc'); final client = auth.configureClient('uc');
@ -156,7 +158,7 @@ class AttachmentProvider extends GetConnect {
'metadata': metadata, 'metadata': metadata,
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return AttachmentPlaceholder.fromJson(resp.body); return AttachmentPlaceholder.fromJson(resp.body);
@ -169,7 +171,7 @@ class AttachmentProvider extends GetConnect {
String cid, String cid,
) async { ) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient( final client = auth.configureClient(
'uc', 'uc',
@ -181,7 +183,7 @@ class AttachmentProvider extends GetConnect {
}); });
final resp = await client.post('/attachments/multipart/$rid/$cid', payload); final resp = await client.post('/attachments/multipart/$rid/$cid', payload);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return Attachment.fromJson(resp.body); return Attachment.fromJson(resp.body);
@ -193,7 +195,7 @@ class AttachmentProvider extends GetConnect {
bool isMature = false, bool isMature = false,
}) async { }) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('files'); final client = auth.configureClient('files');
@ -203,7 +205,7 @@ class AttachmentProvider extends GetConnect {
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -211,13 +213,13 @@ class AttachmentProvider extends GetConnect {
Future<Response> deleteAttachment(int id) async { Future<Response> deleteAttachment(int id) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('files'); final client = auth.configureClient('files');
var resp = await client.delete('/attachments/$id'); var resp = await client.delete('/attachments/$id');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/account/relative_select.dart'; import 'package:solian/widgets/account/relative_select.dart';
@ -16,7 +18,7 @@ class ChannelProvider extends GetxController {
Future<void> refreshAvailableChannel() async { Future<void> refreshAvailableChannel() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
isLoading.value = true; isLoading.value = true;
final resp = await listAvailableChannel(); final resp = await listAvailableChannel();
@ -29,13 +31,13 @@ class ChannelProvider extends GetxController {
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();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/$alias'); final resp = await client.get('/channels/$realm/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -44,13 +46,13 @@ class ChannelProvider extends GetxController {
Future<Response> getMyChannelProfile(String alias, Future<Response> getMyChannelProfile(String alias,
{String realm = 'global'}) async { {String realm = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/$alias/me'); final resp = await client.get('/channels/$realm/$alias/me');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -59,7 +61,7 @@ class ChannelProvider extends GetxController {
Future<Response?> getChannelOngoingCall(String alias, Future<Response?> getChannelOngoingCall(String alias,
{String realm = 'global'}) async { {String realm = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
@ -67,7 +69,7 @@ class ChannelProvider extends GetxController {
if (resp.statusCode == 404) { if (resp.statusCode == 404) {
return null; return null;
} else if (resp.statusCode != 200) { } else if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -75,13 +77,13 @@ class ChannelProvider extends GetxController {
Future<Response> listChannel({String scope = 'global'}) async { Future<Response> listChannel({String scope = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.get('/channels/$scope'); final resp = await client.get('/channels/$scope');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -89,13 +91,13 @@ class ChannelProvider extends GetxController {
Future<Response> listAvailableChannel({String realm = 'global'}) async { Future<Response> listAvailableChannel({String realm = 'global'}) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/me/available'); final resp = await client.get('/channels/$realm/me/available');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -103,13 +105,13 @@ class ChannelProvider extends GetxController {
Future<Response> createChannel(String scope, dynamic payload) async { Future<Response> createChannel(String scope, dynamic payload) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.post('/channels/$scope', payload); final resp = await client.post('/channels/$scope', payload);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -118,7 +120,7 @@ class ChannelProvider extends GetxController {
Future<Response?> createDirectChannel( Future<Response?> createDirectChannel(
BuildContext context, String scope) async { BuildContext context, String scope) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final related = await showModalBottomSheet( final related = await showModalBottomSheet(
useRootNavigator: true, useRootNavigator: true,
@ -141,7 +143,7 @@ class ChannelProvider extends GetxController {
'is_encrypted': false, 'is_encrypted': false,
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -149,13 +151,13 @@ class ChannelProvider extends GetxController {
Future<Response> updateChannel(String scope, int id, dynamic payload) async { Future<Response> updateChannel(String scope, int id, dynamic payload) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging'); final client = auth.configureClient('messaging');
final resp = await client.put('/channels/$scope/$id', payload); final resp = await client.put('/channels/$scope/$id', payload);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;

View File

@ -1,4 +1,6 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
@ -28,7 +30,7 @@ class PostProvider extends GetConnect {
: '/recommendations/$channel?${queries.join('&')}', : '/recommendations/$channel?${queries.join('&')}',
); );
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;
@ -36,7 +38,7 @@ class PostProvider extends GetConnect {
Future<Response> listDraft(int page) async { Future<Response> listDraft(int page) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final queries = [ final queries = [
'take=${10}', 'take=${10}',
@ -45,7 +47,7 @@ class PostProvider extends GetConnect {
final client = auth.configureClient('interactive'); final client = auth.configureClient('interactive');
final resp = await client.get('/posts/drafts?${queries.join('&')}'); final resp = await client.get('/posts/drafts?${queries.join('&')}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;
@ -63,7 +65,7 @@ class PostProvider extends GetConnect {
]; ];
final resp = await get('/posts?${queries.join('&')}'); final resp = await get('/posts?${queries.join('&')}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;
@ -72,7 +74,7 @@ class PostProvider extends GetConnect {
Future<Response> listPostReplies(String alias, int page) async { Future<Response> listPostReplies(String alias, int page) async {
final resp = await get('/posts/$alias/replies?take=${10}&offset=$page'); final resp = await get('/posts/$alias/replies?take=${10}&offset=$page');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;
@ -81,7 +83,7 @@ class PostProvider extends GetConnect {
Future<Response> getPost(String alias) async { Future<Response> getPost(String alias) async {
final resp = await get('/posts/$alias'); final resp = await get('/posts/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;
@ -90,7 +92,7 @@ class PostProvider extends GetConnect {
Future<Response> getArticle(String alias) async { Future<Response> getArticle(String alias) async {
final resp = await get('/articles/$alias'); final resp = await get('/articles/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.body); throw RequestException(resp);
} }
return resp; return resp;

View File

@ -1,4 +1,6 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
@ -8,7 +10,7 @@ class RealmProvider extends GetxController {
Future<void> refreshAvailableRealms() async { Future<void> refreshAvailableRealms() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
isLoading.value = true; isLoading.value = true;
final resp = await listAvailableRealm(); final resp = await listAvailableRealm();
@ -21,13 +23,13 @@ class RealmProvider extends GetxController {
Future<Response> getRealm(String alias) async { Future<Response> getRealm(String alias) async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
final resp = await client.get('/realms/$alias'); final resp = await client.get('/realms/$alias');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -35,13 +37,13 @@ class RealmProvider extends GetxController {
Future<Response> listAvailableRealm() async { Future<Response> listAvailableRealm() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized'); if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
final resp = await client.get('/realms/me/available'); final resp = await client.get('/realms/me/available');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;

View File

@ -1,5 +1,6 @@
import 'package:floor/floor.dart'; import 'package:floor/floor.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart'; import 'package:solian/models/event.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
@ -29,20 +30,20 @@ Future<Event?> getRemoteEvent(int id, Channel channel, String scope) async {
if (resp.statusCode == 404) { if (resp.statusCode == 404) {
return null; return null;
} else if (resp.statusCode != 200) { } else if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return Event.fromJson(resp.body); return Event.fromJson(resp.body);
} }
Future<(List<Event>, int)?> getRemoteEvents( Future<(List<Event>, int)?> getRemoteEvents(
Channel channel, Channel channel,
String scope, { String scope, {
required int remainDepth, required int remainDepth,
bool Function(List<Event> items)? onBrake, bool Function(List<Event> items)? onBrake,
take = 10, take = 10,
offset = 0, offset = 0,
}) async { }) async {
if (remainDepth <= 0) { if (remainDepth <= 0) {
return null; return null;
} }
@ -57,7 +58,7 @@ Future<(List<Event>, int)?> getRemoteEvents(
); );
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
final PaginationResult response = PaginationResult.fromJson(resp.body); final PaginationResult response = PaginationResult.fromJson(resp.body);
@ -69,13 +70,13 @@ Future<(List<Event>, int)?> getRemoteEvents(
} }
final expandResult = (await getRemoteEvents( final expandResult = (await getRemoteEvents(
channel, channel,
scope, scope,
remainDepth: remainDepth - 1, remainDepth: remainDepth - 1,
take: take, take: take,
offset: offset + result.length, offset: offset + result.length,
)) ))
?.$1 ?? ?.$1 ??
List.empty(); List.empty();
return ([...result, ...expandResult], response.count); return ([...result, ...expandResult], response.count);

View File

@ -1,4 +1,5 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.dart';
import 'package:solian/models/relations.dart'; import 'package:solian/models/relations.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
@ -42,7 +43,7 @@ class RelationshipProvider extends GetxController {
final client = auth.configureClient('auth'); final client = auth.configureClient('auth');
final resp = await client.post('/users/me/relations?related=$username', {}); final resp = await client.post('/users/me/relations?related=$username', {});
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -57,7 +58,7 @@ class RelationshipProvider extends GetxController {
{}, {},
); );
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;
@ -71,7 +72,7 @@ class RelationshipProvider extends GetxController {
{'status': status}, {'status': status},
); );
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
return resp; return resp;

View File

@ -6,6 +6,7 @@ import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/models/notification.dart'; import 'package:solian/models/notification.dart';
import 'package:solian/models/packet.dart'; import 'package:solian/models/packet.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
@ -50,31 +51,31 @@ class WebSocketProvider extends GetxController {
} }
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
await auth.ensureCredentials();
if (auth.credentials == null) await auth.loadCredentials();
final uri = Uri.parse(ServiceFinder.buildUrl(
'dealer',
'/api/ws?tk=${auth.credentials!.accessToken}',
).replaceFirst('http', 'ws'));
isConnecting.value = true;
try { try {
await auth.ensureCredentials();
final uri = Uri.parse(ServiceFinder.buildUrl(
'dealer',
'/api/ws?tk=${auth.credentials!.accessToken}',
).replaceFirst('http', 'ws'));
isConnecting.value = true;
websocket = WebSocketChannel.connect(uri); websocket = WebSocketChannel.connect(uri);
await websocket?.ready; await websocket?.ready;
} catch (e) { listen();
isConnected.value = true;
} catch (err) {
log('Unable connect dealer via websocket... $err');
if (!noRetry) { if (!noRetry) {
await auth.refreshCredentials(); await auth.refreshCredentials();
return connect(noRetry: true); return connect(noRetry: true);
} }
} finally {
isConnecting.value = false;
} }
listen();
isConnected.value = true;
isConnecting.value = false;
} }
void disconnect() { void disconnect() {
@ -148,7 +149,7 @@ class WebSocketProvider extends GetxController {
'device_id': deviceUuid, 'device_id': deviceUuid,
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
} }

View File

@ -109,11 +109,11 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final AttachmentProvider provider = Get.find(); final AttachmentProvider attach = Get.find();
Attachment? attachResult; Attachment? attachResult;
try { try {
attachResult = await provider.createAttachmentDirectly( attachResult = await attach.createAttachmentDirectly(
await file.readAsBytes(), await file.readAsBytes(),
file.path, file.path,
'avatar', 'avatar',

View File

@ -13,8 +13,13 @@ import 'package:livekit_client/livekit_client.dart' as livekit;
class CallScreen extends StatefulWidget { class CallScreen extends StatefulWidget {
final bool hideAppBar; final bool hideAppBar;
final bool isExpandable;
const CallScreen({super.key, this.hideAppBar = false}); const CallScreen({
super.key,
this.hideAppBar = false,
this.isExpandable = false,
});
@override @override
State<CallScreen> createState() => _CallScreenState(); State<CallScreen> createState() => _CallScreenState();
@ -308,13 +313,24 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
), ),
); );
}), }),
IconButton( Row(
icon: _layoutMode == 0 children: [
? const Icon(Icons.view_list) if (widget.isExpandable)
: const Icon(Icons.grid_view), IconButton(
onPressed: () { icon: const Icon(Icons.fullscreen),
_switchLayout(); onPressed: () {
}, ctrl.gotoScreen(context);
},
),
IconButton(
icon: _layoutMode == 0
? const Icon(Icons.view_list)
: const Icon(Icons.grid_view),
onPressed: () {
_switchLayout();
},
),
],
), ),
], ],
).paddingOnly(left: 20, right: 16), ).paddingOnly(left: 20, right: 16),

View File

@ -141,7 +141,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
switch (state) { switch (state) {
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
if (_isOutOfSyncSince == null) break; if (_isOutOfSyncSince == null) break;
if (DateTime.now().difference(_isOutOfSyncSince!).inSeconds < 60) break; if (DateTime.now().difference(_isOutOfSyncSince!).inSeconds < 30) break;
_keepUpdateWithServer(); _keepUpdateWithServer();
break; break;
case AppLifecycleState.paused: case AppLifecycleState.paused:
@ -269,26 +269,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
}, },
), ),
), ),
if (_isOutOfSyncSince != null)
ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 8),
tileColor:
Theme.of(context).colorScheme.surfaceContainerLow,
leading: const Icon(Icons.history_toggle_off),
title: Text('messageOutOfSync'.tr),
subtitle: Text('messageOutOfSyncCaption'.tr),
trailing: IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() => _isOutOfSyncSince = null);
},
),
onTap: _isBusy
? null
: () {
_keepUpdateWithServer();
},
),
Obx(() { Obx(() {
if (_chatController.isLoading.isTrue) { if (_chatController.isLoading.isTrue) {
return const LinearProgressIndicator().animate().slideY(); return const LinearProgressIndicator().animate().slideY();
@ -331,7 +311,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
child: Row(children: [ child: Row(children: [
VerticalDivider(width: 0.3, thickness: 0.3), VerticalDivider(width: 0.3, thickness: 0.3),
Expanded( Expanded(
child: CallScreen(hideAppBar: true), child: CallScreen(
hideAppBar: true,
isExpandable: true,
),
), ),
]), ]),
); );

View File

@ -80,13 +80,15 @@ class _ChatScreenState extends State<ChatScreen> {
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 8),
), ),
onTap: () { onTap: () {
final ChannelProvider provider = Get.find(); final ChannelProvider channels = Get.find();
provider channels
.createDirectChannel(context, 'global') .createDirectChannel(context, 'global')
.then((resp) { .then((resp) {
if (resp != null) { if (resp != null) {
_channels.refreshAvailableChannel(); _channels.refreshAvailableChannel();
} }
}).catchError((e) {
context.showErrorDialog(e);
}); });
}, },
), ),
@ -125,6 +127,7 @@ class _ChatScreenState extends State<ChatScreen> {
noCategory: true, noCategory: true,
channels: _channels.directChannels, channels: _channels.directChannels,
selfId: selfId, selfId: selfId,
useReplace: true,
), ),
), ),
), ),

View File

@ -41,6 +41,8 @@ const i18nEnglish = {
'openInBrowser': 'Open in browser', 'openInBrowser': 'Open in browser',
'notification': 'Notification', 'notification': 'Notification',
'errorHappened': 'An error occurred', 'errorHappened': 'An error occurred',
'errorHappenedUnauthorized':
'Unauthorized request, please sign in or try resign in.',
'forgotPassword': 'Forgot password', 'forgotPassword': 'Forgot password',
'email': 'Email', 'email': 'Email',
'username': 'Username', 'username': 'Username',
@ -381,4 +383,6 @@ const i18nEnglish = {
'Since the App has entered the background, there may be a time difference between the message list and the server. Click to Refresh.', 'Since the App has entered the background, there may be a time difference between the message list and the server. Click to Refresh.',
'messageHistoryWipe': 'Wipe local message history', 'messageHistoryWipe': 'Wipe local message history',
'unknown': 'Unknown', 'unknown': 'Unknown',
'collapse': 'Collapse',
'expand': 'Expand',
}; };

View File

@ -41,6 +41,12 @@ const i18nSimplifiedChinese = {
'openInBrowser': '在浏览器中打开', 'openInBrowser': '在浏览器中打开',
'notification': '通知', 'notification': '通知',
'errorHappened': '发生错误了', 'errorHappened': '发生错误了',
'errorHappenedUnauthorized': '未经授权的请求,请登录或尝试重新登录。',
'errorHappenedRequestBad': '请求错误,服务器拒绝处理该请求,请检查您的请求数据。',
'errorHappenedRequestForbidden': '请求错误,权限不足。',
'errorHappenedRequestNotFound': '请求错误,请求的数据不存在。',
'errorHappenedRequestConnection': '网络请求失败,请检查连接状态与服务状态后再试。',
'errorHappenedRequestUnknown': '请求错误,类型未知,请将本提示完整截图提交反馈。',
'forgotPassword': '忘记密码', 'forgotPassword': '忘记密码',
'email': '邮件地址', 'email': '邮件地址',
'username': '用户名', 'username': '用户名',
@ -347,4 +353,6 @@ const i18nSimplifiedChinese = {
'messageOutOfSyncCaption': '由于 App 进入后台,消息列表可能与服务器存在时差,点击刷新。', 'messageOutOfSyncCaption': '由于 App 进入后台,消息列表可能与服务器存在时差,点击刷新。',
'messageHistoryWipe': '清除消息记录', 'messageHistoryWipe': '清除消息记录',
'unknown': '未知', 'unknown': '未知',
'collapse': '折叠',
'expand': '展开',
}; };

View File

@ -28,11 +28,11 @@ class _AttachmentAttrEditorDialogState
bool _isMature = false; bool _isMature = false;
Future<Attachment?> _updateAttachment() async { Future<Attachment?> _updateAttachment() async {
final AttachmentProvider provider = Get.find(); final AttachmentProvider attach = Get.find();
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final resp = await provider.updateAttachment( final resp = await attach.updateAttachment(
widget.item.id, widget.item.id,
_altController.value.text, _altController.value.text,
isMature: _isMature, isMature: _isMature,

View File

@ -157,7 +157,7 @@ class _AttachmentListState extends State<AttachmentList> {
final element = _attachmentsMeta[idx]; final element = _attachmentsMeta[idx];
idx++; idx++;
if (element == null) return const SizedBox(); if (element == null) return const SizedBox();
double ratio = element.metadata!['ratio']?.toDouble() ?? 16 / 9; double ratio = element.metadata?['ratio']?.toDouble() ?? 16 / 9;
return Container( return Container(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: widget.columnMaxWidth, maxWidth: widget.columnMaxWidth,

View File

@ -239,7 +239,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
var insertText = ''; var insertText = '';
if (suggestion.type == 'emotes') { if (suggestion.type == 'emotes') {
insertText = suggestion.content; insertText = '${suggestion.content} ';
startText = replaceText.replaceFirstMapped( startText = replaceText.replaceFirstMapped(
RegExp(r':(?:([-\w]+)~)?([-\w]+)$'), RegExp(r':(?:([-\w]+)~)?([-\w]+)$'),
(Match m) => insertText, (Match m) => insertText,
@ -247,7 +247,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
} }
if (suggestion.type == 'users') { if (suggestion.type == 'users') {
insertText = suggestion.content; insertText = '${suggestion.content} ';
startText = replaceText.replaceFirstMapped( startText = replaceText.replaceFirstMapped(
RegExp(r'(?:\s|^)@([-\w]+)$'), RegExp(r'(?:\s|^)@([-\w]+)$'),
(Match m) => insertText, (Match m) => insertText,

View File

@ -13,7 +13,7 @@ import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_status_action.dart'; import 'package:solian/widgets/account/account_status_action.dart';
import 'package:solian/widgets/navigation/app_navigation.dart'; import 'package:solian/widgets/navigation/app_navigation.dart';
import 'package:badges/badges.dart' as badges; import 'package:badges/badges.dart' as badges;
import 'package:solian/widgets/navigation/app_navigation_regions.dart'; import 'package:solian/widgets/navigation/app_navigation_region.dart';
class AppNavigationDrawer extends StatefulWidget { class AppNavigationDrawer extends StatefulWidget {
final String? routeName; final String? routeName;
@ -24,7 +24,23 @@ class AppNavigationDrawer extends StatefulWidget {
State<AppNavigationDrawer> createState() => _AppNavigationDrawerState(); State<AppNavigationDrawer> createState() => _AppNavigationDrawerState();
} }
class _AppNavigationDrawerState extends State<AppNavigationDrawer> { class _AppNavigationDrawerState extends State<AppNavigationDrawer>
with TickerProviderStateMixin {
bool _isCollapsed = false;
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; AccountStatus? _accountStatus;
Future<void> _getStatus() async { Future<void> _getStatus() async {
@ -40,137 +56,228 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
} }
} }
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).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() { void _closeDrawer() {
_autoResize();
rootScaffoldKey.currentState!.closeDrawer(); rootScaffoldKey.currentState!.closeDrawer();
} }
void _autoResize() {
if (SolianTheme.isExtraLargeScreen(context)) {
_expandDrawer();
} else if (SolianTheme.isLargeScreen(context)) {
_collapseDrawer();
} else {
_drawerAnimationController.animateTo(
1,
duration: const Duration(milliseconds: 100),
);
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_getStatus(); _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final AuthProvider auth = Get.find(); return AnimatedBuilder(
animation: _drawerAnimation,
return Drawer( builder: (context, child) {
backgroundColor: return Drawer(
SolianTheme.isLargeScreen(context) ? Colors.transparent : null, width: _drawerAnimation.value,
backgroundColor:
SolianTheme.isLargeScreen(context) ? Colors.transparent : null,
child: child,
);
},
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: Column( child: Column(
children: [ children: [
Obx(() { _buildUserInfo().paddingSymmetric(vertical: 8),
if (auth.isAuthorized.isFalse || auth.userProfile.value == null) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 28),
leading: const Icon(Icons.account_circle),
title: Text('guest'.tr),
subtitle: Text('unsignedIn'.tr),
onTap: () {
AppRouter.instance.goNamed('account');
_closeDrawer();
},
);
}
return ListTile(
contentPadding: const EdgeInsets.only(left: 20, right: 20),
title: Text(
auth.userProfile.value!['nick'],
maxLines: 1,
overflow: TextOverflow.fade,
),
subtitle: Builder(
builder: (context) {
if (_accountStatus == null) {
return Text('loading'.tr);
}
final info = StatusProvider.determineStatus(
_accountStatus!,
);
return Text(
info.$3,
maxLines: 1,
overflow: TextOverflow.fade,
);
},
),
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'],
),
),
);
}),
onTap: () {
AppRouter.instance.goNamed('account');
_closeDrawer();
},
onLongPress: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => AccountStatusAction(
currentStatus: _accountStatus!.status,
),
).then((val) {
if (val == true) _getStatus();
});
},
);
}).paddingSymmetric(vertical: 8),
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),
Column( Column(
children: AppNavigation.destinations children: AppNavigation.destinations
.map( .map(
(e) => ListTile( (e) => _isCollapsed
contentPadding: const EdgeInsets.symmetric( ? Tooltip(
horizontal: 20, message: e.label,
), child: InkWell(
leading: Icon(e.icon, size: 20).paddingAll(2), child: Icon(e.icon, size: 20).paddingSymmetric(
title: Text(e.label), horizontal: 28,
enabled: true, vertical: 16,
onTap: () { ),
AppRouter.instance.goNamed(e.page); onTap: () {
_closeDrawer(); AppRouter.instance.goNamed(e.page);
}, _closeDrawer();
), },
),
)
: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
leading: Icon(e.icon, size: 20).paddingAll(2),
title: !_isCollapsed ? Text(e.label) : null,
enabled: true,
onTap: () {
AppRouter.instance.goNamed(e.page);
_closeDrawer();
},
),
) )
.toList(), .toList(),
).paddingSymmetric(vertical: 8), ),
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),
Expanded( Expanded(
child: AppNavigationRegions( child: AppNavigationRegion(
isCollapsed: _isCollapsed,
onSelected: (item) { onSelected: (item) {
_closeDrawer(); _closeDrawer();
}, },
@ -179,18 +286,63 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),
Column( Column(
children: [ children: [
ListTile( if (_isCollapsed)
minTileHeight: 0, Tooltip(
contentPadding: const EdgeInsets.symmetric( message: 'settings'.tr,
horizontal: 20, child: InkWell(
child: const Icon(
Icons.settings,
size: 20,
).paddingSymmetric(
horizontal: 28,
vertical: 10,
),
onTap: () {
AppRouter.instance.pushNamed('settings');
_closeDrawer();
},
),
)
else
ListTile(
minTileHeight: 0,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
),
leading: const Icon(Icons.settings, size: 20).paddingAll(2),
title: Text('settings'.tr),
onTap: () {
AppRouter.instance.pushNamed('settings');
_closeDrawer();
},
),
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();
},
), ),
leading: const Icon(Icons.settings, size: 20).paddingAll(2),
title: Text('settings'.tr),
onTap: () {
AppRouter.instance.pushNamed('settings');
_closeDrawer();
},
),
], ],
).paddingOnly( ).paddingOnly(
top: 8, top: 8,

View File

@ -5,10 +5,15 @@ import 'package:solian/providers/content/channel.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
class AppNavigationRegions extends StatelessWidget { class AppNavigationRegion extends StatelessWidget {
final bool isCollapsed;
final Function(Channel item) onSelected; final Function(Channel item) onSelected;
const AppNavigationRegions({super.key, required this.onSelected}); const AppNavigationRegion({
super.key,
required this.onSelected,
this.isCollapsed = false,
});
void _gotoChannel(Channel item) { void _gotoChannel(Channel item) {
AppRouter.instance.pushReplacementNamed( AppRouter.instance.pushReplacementNamed(
@ -25,6 +30,16 @@ class AppNavigationRegions extends StatelessWidget {
Widget _buildEntry(BuildContext context, Channel item) { Widget _buildEntry(BuildContext context, Channel item) {
const padding = EdgeInsets.symmetric(horizontal: 20); const padding = EdgeInsets.symmetric(horizontal: 20);
if (isCollapsed) {
return InkWell(
child: const Icon(Icons.tag_outlined, size: 20).paddingSymmetric(
horizontal: 20,
vertical: 16,
),
onTap: () => _gotoChannel(item),
);
}
return ListTile( return ListTile(
minTileHeight: 0, minTileHeight: 0,
leading: const Icon(Icons.tag_outlined), leading: const Icon(Icons.tag_outlined),
@ -51,6 +66,27 @@ class AppNavigationRegions extends StatelessWidget {
.where((x) => x.type == 0 && x.realmId != null) .where((x) => x.type == 0 && x.realmId != null)
.toList(); .toList();
if (isCollapsed) {
return CustomScrollView(
slivers: [
const SliverPadding(padding: EdgeInsets.only(top: 8)),
SliverList.builder(
itemCount:
noRealmGroupChannels.length + hasRealmGroupChannels.length,
itemBuilder: (context, index) {
final element = index >= noRealmGroupChannels.length
? hasRealmGroupChannels[index - noRealmGroupChannels.length]
: noRealmGroupChannels[index];
return Tooltip(
message: element.name,
child: _buildEntry(context, element),
);
},
),
],
);
}
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
const SliverPadding(padding: EdgeInsets.only(top: 8)), const SliverPadding(padding: EdgeInsets.only(top: 8)),

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.1+20 version: 1.2.1+21
environment: environment:
sdk: ">=3.3.4 <4.0.0" sdk: ">=3.3.4 <4.0.0"