Compare commits

..

15 Commits

Author SHA1 Message Date
cc9081b011 🐛 Fix image loading issue on web 2024-08-24 11:47:40 +08:00
14e8f7b775 🐛 Bug fixes and optimization 2024-08-23 23:16:41 +08:00
a70e6c7118 Typing indicator 2024-08-23 22:43:04 +08:00
48ca885a2c 🚀 Launch 1.2.1+22 2024-08-22 01:06:10 +08:00
09cb340a9d 🐛 Fix personalize page issue 2024-08-22 00:42:17 +08:00
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
32 changed files with 847 additions and 366 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,22 +1,26 @@
class NetworkPackage { class NetworkPackage {
String method; String method;
String? endpoint;
String? message; String? message;
Map<String, dynamic>? payload; Map<String, dynamic>? payload;
NetworkPackage({ NetworkPackage({
required this.method, required this.method,
this.endpoint,
this.message, this.message,
this.payload, this.payload,
}); });
factory NetworkPackage.fromJson(Map<String, dynamic> json) => NetworkPackage( factory NetworkPackage.fromJson(Map<String, dynamic> json) => NetworkPackage(
method: json['w'], method: json['w'],
endpoint: json['e'],
message: json['m'], message: json['m'],
payload: json['p'], payload: json['p'],
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'w': method, 'w': method,
'e': endpoint,
'm': message, 'm': message,
'p': payload, 'p': payload,
}; };

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() {
@ -87,6 +88,7 @@ class WebSocketProvider extends GetxController {
websocket?.stream.listen( websocket?.stream.listen(
(event) { (event) {
final packet = NetworkPackage.fromJson(jsonDecode(event)); final packet = NetworkPackage.fromJson(jsonDecode(event));
log('Websocket incoming message: ${packet.method} ${packet.message}');
stream.sink.add(packet); stream.sink.add(packet);
}, },
onDone: () { onDone: () {
@ -147,8 +149,8 @@ class WebSocketProvider extends GetxController {
'device_token': token, 'device_token': token,
'device_id': deviceUuid, 'device_id': deviceUuid,
}); });
if (resp.statusCode != 200) { if (resp.statusCode != 200 && resp.statusCode != 400) {
throw Exception(resp.bodyString); throw RequestException(resp);
} }
} }

View File

@ -30,8 +30,8 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
final _birthdayController = TextEditingController(); final _birthdayController = TextEditingController();
int? _avatar; String? _avatar;
int? _banner; String? _banner;
DateTime? _birthday; DateTime? _birthday;
bool _isBusy = false; bool _isBusy = false;
@ -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',
@ -129,7 +129,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final resp = await client.put( final resp = await client.put(
'/users/me/$position', '/users/me/$position',
{'attachment': attachResult.id}, {'attachment': attachResult.rid},
); );
if (resp.statusCode == 200) { if (resp.statusCode == 200) {
_syncWidget(); _syncWidget();

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

@ -24,6 +24,7 @@ import 'package:solian/widgets/channel/channel_call_indicator.dart';
import 'package:solian/widgets/chat/call/chat_call_action.dart'; import 'package:solian/widgets/chat/call/chat_call_action.dart';
import 'package:solian/widgets/chat/chat_event_list.dart'; import 'package:solian/widgets/chat/chat_event_list.dart';
import 'package:solian/widgets/chat/chat_message_input.dart'; import 'package:solian/widgets/chat/chat_message_input.dart';
import 'package:solian/widgets/chat/chat_typing_indicator.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
class ChannelChatScreen extends StatefulWidget { class ChannelChatScreen extends StatefulWidget {
@ -103,12 +104,18 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
setState(() => _isBusy = false); setState(() => _isBusy = false);
} }
final List<ChannelMember> _typingUsers = List.empty(growable: true);
final Map<int, Timer> _typingInactiveTimer = {};
void _listenMessages() { void _listenMessages() {
final WebSocketProvider provider = Get.find(); final WebSocketProvider ws = Get.find();
_subscription = provider.stream.stream.listen((event) { _subscription = ws.stream.stream.listen((event) {
switch (event.method) { switch (event.method) {
case 'events.new': case 'events.new':
final payload = Event.fromJson(event.payload!); final payload = Event.fromJson(event.payload!);
final typingIdx =
_typingUsers.indexWhere((x) => x.id == payload.senderId);
if (typingIdx != -1) _typingUsers.removeAt(typingIdx);
_chatController.receiveEvent(payload); _chatController.receiveEvent(payload);
break; break;
case 'calls.new': case 'calls.new':
@ -123,6 +130,25 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
setState(() => _ongoingCall = null); setState(() => _ongoingCall = null);
} }
break; break;
case 'status.typing':
if (event.payload?['channel_id'] != _channel!.id) break;
final member = ChannelMember.fromJson(event.payload!['member']);
if (member.id == _channelProfile!.id) break;
if (!_typingUsers.any((x) => x.id == member.id)) {
setState(() {
_typingUsers.add(member);
});
}
_typingInactiveTimer[member.id]?.cancel();
_typingInactiveTimer[member.id] = Timer(
const Duration(seconds: 3),
() {
setState(() {
_typingUsers.removeWhere((x) => x.id == member.id);
_typingInactiveTimer.remove(member.id);
});
},
);
} }
}); });
} }
@ -141,7 +167,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 +295,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();
@ -300,23 +306,28 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50),
child: SafeArea( child: SafeArea(
child: ChatMessageInput( child: Column(
edit: _messageToEditing, children: [
reply: _messageToReplying, ChatTypingIndicator(users: _typingUsers),
realm: widget.realm, ChatMessageInput(
placeholder: placeholder, edit: _messageToEditing,
channel: _channel!, reply: _messageToReplying,
onSent: (Event item) { realm: widget.realm,
setState(() { placeholder: placeholder,
_chatController.addPendingEvent(item); channel: _channel!,
}); onSent: (Event item) {
}, setState(() {
onReset: () { _chatController.addPendingEvent(item);
setState(() { });
_messageToReplying = null; },
_messageToEditing = null; onReset: () {
}); setState(() {
}, _messageToReplying = null;
_messageToEditing = null;
});
},
),
],
), ),
), ),
), ),
@ -331,7 +342,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,
),
), ),
]), ]),
); );
@ -346,6 +360,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
@override @override
void dispose() { void dispose() {
for (var timer in _typingInactiveTimer.values) {
timer.cancel();
}
_subscription?.cancel(); _subscription?.cancel();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();

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,7 @@ 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',
'typingMessage': '@user are typing...',
}; };

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,7 @@ const i18nSimplifiedChinese = {
'messageOutOfSyncCaption': '由于 App 进入后台,消息列表可能与服务器存在时差,点击刷新。', 'messageOutOfSyncCaption': '由于 App 进入后台,消息列表可能与服务器存在时差,点击刷新。',
'messageHistoryWipe': '清除消息记录', 'messageHistoryWipe': '清除消息记录',
'unknown': '未知', 'unknown': '未知',
'collapse': '折叠',
'expand': '展开',
'typingMessage': '@user 正在输入中…',
}; };

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

@ -147,17 +147,21 @@ class _AttachmentItemImage extends StatelessWidget {
errorWidget: (context, url, error) { errorWidget: (context, url, error) {
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: Center( child: Column(
child: const Icon(Icons.close, size: 32) mainAxisAlignment: MainAxisAlignment.center,
.animate(onPlay: (e) => e.repeat(reverse: true)) children: [
.fade(duration: 500.ms), const Icon(Icons.close, size: 32)
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
Text(error.toString()),
],
), ),
); );
}, },
) )
else else
Image.network( Image.network(
ServiceFinder.buildUrl('files', '/attachments/${item.id}'), ServiceFinder.buildUrl('files', '/attachments/${item.rid}'),
fit: fit, fit: fit,
loadingBuilder: (BuildContext context, Widget child, loadingBuilder: (BuildContext context, Widget child,
ImageChunkEvent? loadingProgress) { ImageChunkEvent? loadingProgress) {
@ -174,10 +178,14 @@ class _AttachmentItemImage extends StatelessWidget {
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return Material( return Material(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: Center( child: Column(
child: const Icon(Icons.close, size: 32) mainAxisAlignment: MainAxisAlignment.center,
.animate(onPlay: (e) => e.repeat(reverse: true)) children: [
.fade(duration: 500.ms), const Icon(Icons.close, size: 32)
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
Text(error.toString()),
],
), ),
); );
}, },

View File

@ -132,6 +132,9 @@ class _AttachmentListState extends State<AttachmentList> {
_getMetadataList(); _getMetadataList();
} }
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.attachmentsId.isEmpty) { if (widget.attachmentsId.isEmpty) {
@ -139,12 +142,24 @@ class _AttachmentListState extends State<AttachmentList> {
} }
if (_isLoading) { if (_isLoading) {
return Container( return Row(
decoration: BoxDecoration( children: [
color: Theme.of(context).colorScheme.surfaceContainerHigh, Icon(
), Icons.file_copy,
child: const LinearProgressIndicator(), size: 12,
); color: _unFocusColor,
).paddingOnly(right: 5),
Text(
'attachmentHint'.trParams(
{'count': widget.attachmentsId.length.toString()},
),
style: TextStyle(color: _unFocusColor, fontSize: 12),
)
],
)
.paddingSymmetric(horizontal: 8)
.animate(onPlay: (c) => c.repeat(reverse: true))
.fadeIn(duration: 1250.ms);
} }
if (widget.isColumn) { if (widget.isColumn) {
@ -157,8 +172,11 @@ 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(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: widget.columnMaxWidth, maxWidth: widget.columnMaxWidth,
maxHeight: 640, maxHeight: 640,
@ -204,6 +222,7 @@ class _AttachmentListState extends State<AttachmentList> {
final element = _attachmentsMeta[idx]; final element = _attachmentsMeta[idx];
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
border: Border.all( border: Border.all(
color: Theme.of(context).dividerColor, color: Theme.of(context).dividerColor,
width: 1, width: 1,

View File

@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart';
@ -7,10 +10,12 @@ import 'package:solian/exts.dart';
import 'package:solian/models/account.dart'; import 'package:solian/models/account.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/packet.dart';
import 'package:solian/platform.dart'; import 'package:solian/platform.dart';
import 'package:solian/providers/attachment_uploader.dart'; import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/stickers.dart'; import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/attachments/attachment_editor.dart'; import 'package:solian/widgets/attachments/attachment_editor.dart';
import 'package:solian/widgets/chat/chat_event.dart'; import 'package:solian/widgets/chat/chat_event.dart';
@ -196,6 +201,36 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
} }
} }
Timer? _typingNotifyTimer;
bool _typingStatus = false;
Future<void> _sendTypingStatus() async {
final WebSocketProvider ws = Get.find();
ws.websocket?.sink.add(jsonEncode(
NetworkPackage(
method: 'status.typing',
endpoint: 'messaging',
payload: {
'channel_id': widget.channel.id,
},
).toJson(),
));
}
void _pingEnterMessageStatus() {
if (!_typingStatus) {
_sendTypingStatus();
_typingStatus = true;
}
if (_typingNotifyTimer == null || !_typingNotifyTimer!.isActive) {
_typingNotifyTimer?.cancel();
_typingNotifyTimer = Timer(const Duration(milliseconds: 1850), () {
_typingStatus = false;
});
}
}
void _resetInput() { void _resetInput() {
if (widget.onReset != null) widget.onReset!(); if (widget.onReset != null) widget.onReset!();
_editTo = null; _editTo = null;
@ -239,7 +274,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 +282,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,
@ -269,6 +304,20 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
} }
@override
void initState() {
super.initState();
_textController.addListener(_pingEnterMessageStatus);
}
@override
void dispose() {
_textController.removeListener(_pingEnterMessageStatus);
_textController.dispose();
_typingNotifyTimer?.cancel();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final notifyBannerActions = [ final notifyBannerActions = [

View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/models/channel.dart';
class ChatTypingIndicator extends StatefulWidget {
final List<ChannelMember> users;
const ChatTypingIndicator({super.key, required this.users});
@override
State<ChatTypingIndicator> createState() => _ChatTypingIndicatorState();
}
class _ChatTypingIndicatorState extends State<ChatTypingIndicator>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
late final Animation<double> _animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant ChatTypingIndicator oldWidget) {
if (widget.users.isNotEmpty) {
_controller.animateTo(1);
} else {
_controller.animateTo(0);
}
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
return SizeTransition(
sizeFactor: _animation,
axis: Axis.vertical,
axisAlignment: -1,
child: Row(
children: [
const Icon(Icons.more_horiz),
const SizedBox(width: 6),
Text('typingMessage'.trParams({
'user': widget.users.map((x) => x.account.nick).join(', '),
})),
],
).paddingSymmetric(horizontal: 16),
);
}
}

View File

@ -1,5 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -17,8 +18,36 @@ class LinkExpansion extends StatelessWidget {
return SvgPicture.network(url, width: width, height: height); return SvgPicture.network(url, width: width, height: height);
} }
return PlatformInfo.canCacheImage return PlatformInfo.canCacheImage
? CachedNetworkImage(imageUrl: url, width: width, height: height) ? CachedNetworkImage(
: Image.network(url, width: width, height: height); imageUrl: url,
width: width,
height: height,
errorWidget: (context, url, error) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Center(
child: const Icon(Icons.close, size: 32)
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
),
);
},
)
: Image.network(
url,
width: width,
height: height,
errorBuilder: (context, error, stackTrace) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Center(
child: const Icon(Icons.close, size: 32)
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
),
);
},
);
} }
@override @override

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,225 @@ 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.value = 1;
}
}
@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 +283,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

@ -865,10 +865,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: ddc16d34b0d74cb313986918c0f0885a7ba2fc24d8fb8419de75f0015144ccfe sha256: "2ddb88e9ad56ae15ee144ed10e33886777eb5ca2509a914850a5faa7b52ff459"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.3" version: "14.2.7"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:
@ -953,10 +953,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_android name: image_picker_android
sha256: "8c5abf0dcc24fe6e8e0b4a5c0b51a5cf30cefdf6407a3213dae61edc75a70f56" sha256: c0a6763d50b354793d0192afd0a12560b823147d3ded7c6b77daf658fa05cc85
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.12+12" version: "0.8.12+13"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@ -1153,10 +1153,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit name: media_kit
sha256: "3289062540e3b8b9746e5c50d95bd78a9289826b7227e253dff806d002b9e67a" sha256: "1f1deee148533d75129a6f38251ff8388e33ee05fc2d20a6a80e57d6051b7b62"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.10+1" version: "1.1.11"
media_kit_libs_android_video: media_kit_libs_android_video:
dependency: transitive dependency: transitive
description: description:
@ -1193,34 +1193,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_libs_video name: media_kit_libs_video
sha256: "3688e0c31482074578652bf038ce6301a5d21e1eda6b54fc3117ffeb4bdba067" sha256: "20bb4aefa8fece282b59580e1cd8528117297083a6640c98c2e98cfc96b93288"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "1.0.5"
media_kit_libs_windows_video: media_kit_libs_windows_video:
dependency: transitive dependency: transitive
description: description:
name: media_kit_libs_windows_video name: media_kit_libs_windows_video
sha256: "7bace5f35d9afcc7f9b5cdadb7541d2191a66bb3fc71bfa11c1395b3360f6122" sha256: "32654572167825c42c55466f5d08eee23ea11061c84aa91b09d0e0f69bdd0887"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.10"
media_kit_native_event_loop: media_kit_native_event_loop:
dependency: transitive dependency: transitive
description: description:
name: media_kit_native_event_loop name: media_kit_native_event_loop
sha256: a605cf185499d14d58935b8784955a92a4bf0ff4e19a23de3d17a9106303930e sha256: "7d82e3b3e9ded5c35c3146c5ba1da3118d1dd8ac3435bac7f29f458181471b40"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.9"
media_kit_video: media_kit_video:
dependency: "direct main" dependency: "direct main"
description: description:
name: media_kit_video name: media_kit_video
sha256: c048d11a19e379aebbe810647636e3fc6d18374637e2ae12def4ff8a4b99a882 sha256: "2cc3b966679963ba25a4ce5b771e532a521ebde7c6aa20e9802bec95d9916c8f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.4" version: "1.2.5"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -1385,10 +1385,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_html name: permission_handler_html
sha256: d220eb8476b466d58b161e10b3001d93999010a26228a3fb89c4280db1249546 sha256: af26edbbb1f2674af65a8f4b56e1a6f526156bc273d0e65dd8075fab51c78851
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.3+1" version: "0.1.3+2"
permission_handler_platform_interface: permission_handler_platform_interface:
dependency: transitive dependency: transitive
description: description:
@ -1481,10 +1481,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: process_run name: process_run
sha256: c917dfb5f7afad4c7485bc00a4df038621248fce046105020cea276d1a87c820 sha256: "112a77da35be50617ed9e2230df68d0817972f225e7f97ce8336f76b4e601606"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.2.0"
protobuf: protobuf:
dependency: transitive dependency: transitive
description: description:
@ -1934,10 +1934,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_android name: url_launcher_android
sha256: f0c73347dfcfa5b3db8bc06e1502668265d39c08f310c29bff4e28eea9699f79 sha256: e35a698ac302dd68e41f73250bd9517fe3ab5fa4f18fe4647a0872db61bacbab
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.9" version: "6.3.10"
url_launcher_ios: url_launcher_ios:
dependency: transitive dependency: transitive
description: description:
@ -2140,4 +2140,4 @@ packages:
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.5.0 <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.22.0" flutter: ">=3.24.0"

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+23
environment: environment:
sdk: ">=3.3.4 <4.0.0" sdk: ">=3.3.4 <4.0.0"