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" />
@ -24,14 +24,14 @@
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>
@ -58,6 +58,13 @@
<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>
@ -66,21 +73,21 @@
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,6 +112,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
label: 'bsPreparingData', label: 'bsPreparingData',
action: () async { action: () async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
try {
await Future.wait([ await Future.wait([
Get.find<StickerProvider>().refreshAvailableStickers(), Get.find<StickerProvider>().refreshAvailableStickers(),
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
@ -121,6 +122,9 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
if (auth.isAuthorized.isTrue) if (auth.isAuthorized.isTrue)
Get.find<RealmProvider>().refreshAvailableRealms(), 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,7 +30,7 @@ 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);
@ -42,7 +43,7 @@ Future<(List<Event>, int)?> getRemoteEvents(
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);

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,9 +51,9 @@ class WebSocketProvider extends GetxController {
} }
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
await auth.ensureCredentials();
if (auth.credentials == null) await auth.loadCredentials(); try {
await auth.ensureCredentials();
final uri = Uri.parse(ServiceFinder.buildUrl( final uri = Uri.parse(ServiceFinder.buildUrl(
'dealer', 'dealer',
@ -61,21 +62,21 @@ class WebSocketProvider extends GetxController {
isConnecting.value = true; isConnecting.value = true;
try {
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 {
listen();
isConnected.value = true;
isConnecting.value = false; isConnecting.value = false;
} }
}
void disconnect() { void disconnect() {
websocket?.sink.close(WebSocketStatus.normalClosure); websocket?.sink.close(WebSocketStatus.normalClosure);
@ -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,6 +313,15 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
), ),
); );
}), }),
Row(
children: [
if (widget.isExpandable)
IconButton(
icon: const Icon(Icons.fullscreen),
onPressed: () {
ctrl.gotoScreen(context);
},
),
IconButton( IconButton(
icon: _layoutMode == 0 icon: _layoutMode == 0
? const Icon(Icons.view_list) ? const Icon(Icons.view_list)
@ -317,6 +331,8 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
}, },
), ),
], ],
),
],
).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,34 +56,19 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
} }
} }
void _closeDrawer() { Color get _unFocusColor =>
rootScaffoldKey.currentState!.closeDrawer(); Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
}
@override Widget _buildUserInfo() {
void initState() { return Obx(() {
super.initState();
_getStatus();
}
@override
Widget build(BuildContext context) {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
return Drawer(
backgroundColor:
SolianTheme.isLargeScreen(context) ? Colors.transparent : null,
child: SafeArea(
bottom: false,
child: Column(
children: [
Obx(() {
if (auth.isAuthorized.isFalse || auth.userProfile.value == null) { if (auth.isAuthorized.isFalse || auth.userProfile.value == null) {
return ListTile( if (_isCollapsed) {
contentPadding: const EdgeInsets.symmetric(horizontal: 28), return InkWell(
leading: const Icon(Icons.account_circle), child: const Icon(Icons.account_circle).paddingSymmetric(
title: Text('guest'.tr), horizontal: 28,
subtitle: Text('unsignedIn'.tr), vertical: 20,
),
onTap: () { onTap: () {
AppRouter.instance.goNamed('account'); AppRouter.instance.goNamed('account');
_closeDrawer(); _closeDrawer();
@ -76,37 +77,24 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
} }
return ListTile( return ListTile(
contentPadding: const EdgeInsets.only(left: 20, right: 20), contentPadding: const EdgeInsets.symmetric(horizontal: 28),
title: Text( leading: const Icon(Icons.account_circle),
auth.userProfile.value!['nick'], title: !_isCollapsed ? Text('guest'.tr) : null,
maxLines: 1, subtitle: !_isCollapsed ? Text('unsignedIn'.tr) : null,
overflow: TextOverflow.fade, onTap: () {
), AppRouter.instance.goNamed('account');
subtitle: Builder( _closeDrawer();
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 leading = Obx(() {
final statusBadgeColor = _accountStatus != null final statusBadgeColor = _accountStatus != null
? StatusProvider.determineStatus( ? StatusProvider.determineStatus(_accountStatus!).$2
_accountStatus!,
).$2
: Colors.grey; : Colors.grey;
final RelationshipProvider relations = Get.find(); final RelationshipProvider relations = Get.find();
final accountNotifications = final accountNotifications = relations.friendRequestCount.value;
relations.friendRequestCount.value;
return badges.Badge( return badges.Badge(
badgeContent: Text( badgeContent: Text(
@ -120,8 +108,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
), ),
child: badges.Badge( child: badges.Badge(
showBadge: _accountStatus != null, showBadge: _accountStatus != null,
badgeStyle: badgeStyle: badges.BadgeStyle(badgeColor: statusBadgeColor),
badges.BadgeStyle(badgeColor: statusBadgeColor),
position: badges.BadgePosition.bottomEnd( position: badges.BadgePosition.bottomEnd(
bottom: 0, bottom: 0,
end: -2, end: -2,
@ -131,7 +118,47 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
), ),
), ),
); );
}), });
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: () { onTap: () {
AppRouter.instance.goNamed('account'); AppRouter.instance.goNamed('account');
_closeDrawer(); _closeDrawer();
@ -148,17 +175,96 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
}); });
}, },
); );
}).paddingSymmetric(vertical: 8), });
}
void _expandDrawer() {
_drawerAnimationController.animateTo(1);
}
void _collapseDrawer() {
_drawerAnimationController.animateTo(0);
}
void _closeDrawer() {
_autoResize();
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
void initState() {
super.initState();
_getStatus();
Future.delayed(Duration.zero, () => _autoResize());
_drawerAnimationController.addListener(() {
if (_drawerAnimation.value > 180 && _isCollapsed) {
setState(() => _isCollapsed = false);
} else if (_drawerAnimation.value < 180 && !_isCollapsed) {
setState(() => _isCollapsed = true);
}
});
}
@override
void dispose() {
_drawerAnimationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _drawerAnimation,
builder: (context, child) {
return Drawer(
width: _drawerAnimation.value,
backgroundColor:
SolianTheme.isLargeScreen(context) ? Colors.transparent : null,
child: child,
);
},
child: SafeArea(
bottom: false,
child: Column(
children: [
_buildUserInfo().paddingSymmetric(vertical: 8),
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),
Column( Column(
children: AppNavigation.destinations children: AppNavigation.destinations
.map( .map(
(e) => ListTile( (e) => _isCollapsed
? Tooltip(
message: e.label,
child: InkWell(
child: Icon(e.icon, size: 20).paddingSymmetric(
horizontal: 28,
vertical: 16,
),
onTap: () {
AppRouter.instance.goNamed(e.page);
_closeDrawer();
},
),
)
: ListTile(
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 20, horizontal: 20,
), ),
leading: Icon(e.icon, size: 20).paddingAll(2), leading: Icon(e.icon, size: 20).paddingAll(2),
title: Text(e.label), title: !_isCollapsed ? Text(e.label) : null,
enabled: true, enabled: true,
onTap: () { onTap: () {
AppRouter.instance.goNamed(e.page); AppRouter.instance.goNamed(e.page);
@ -167,10 +273,11 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
), ),
) )
.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,6 +286,24 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),
Column( Column(
children: [ children: [
if (_isCollapsed)
Tooltip(
message: 'settings'.tr,
child: InkWell(
child: const Icon(
Icons.settings,
size: 20,
).paddingSymmetric(
horizontal: 28,
vertical: 10,
),
onTap: () {
AppRouter.instance.pushNamed('settings');
_closeDrawer();
},
),
)
else
ListTile( ListTile(
minTileHeight: 0, minTileHeight: 0,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@ -191,6 +316,33 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
_closeDrawer(); _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();
},
),
], ],
).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"