Compare commits

...

40 Commits

Author SHA1 Message Date
19eabfaba1 🚀 Launch 1.2.1+2 2024-08-04 13:27:14 +08:00
ec2eadad6d 🐛 Fix bootstrapper icon issue 2024-08-04 12:59:13 +08:00
54e176e75d 🐛 Fix post editor cannot reply either repost 2024-08-04 12:55:05 +08:00
0a7ccaeefa 🐛 Fix attachment editor title overflow 2024-08-04 12:23:39 +08:00
a5f093e185 🐛 Fix unauthorized wont load stickers 2024-08-04 11:10:25 +08:00
a4f68dd175 🚀 Launch 1.2.1+1 2024-08-04 01:54:35 +08:00
8067c35c70 Follow the manifest to load emotes 2024-08-04 01:53:52 +08:00
ebe381053e Load emojis 2024-08-04 01:37:54 +08:00
03f2470dae Basic sticker management 2024-08-04 01:03:09 +08:00
ea434815cf Create sticker
 Single file mode attachment editor and more options
2024-08-03 21:29:48 +08:00
bbea4b4359 🍱 Update app icons 2024-08-03 17:44:36 +08:00
e0b485cc81 🐛 Fix mis-style 2024-08-03 14:00:52 +08:00
87bb37ac01 ⚗️ Markdown embed content 2024-08-03 12:29:13 +08:00
989b5babd9 Auto update checking 2024-08-03 01:14:42 +08:00
9ea364640d 🚀 Launch 1.2.0+8 2024-08-02 23:24:36 +08:00
a9f55a489d ⬆️ Clean and upgrade packages 2024-08-02 23:22:50 +08:00
4616f3a3e2 Friend request indicator 2024-08-02 23:15:28 +08:00
425bae9d13 💄 Better friend page loading indicator 2024-08-02 22:54:56 +08:00
07771e8979 Improve the speed of fetching attachments meta via batch api 2024-08-02 22:46:48 +08:00
0ad4854443 💄 Grid view in call 2024-08-02 21:12:37 +08:00
4238ea6fdc Call grid layout 2024-08-02 18:49:28 +08:00
7d45c06302 💄 Optimized signal indicator 2024-08-02 18:29:01 +08:00
7e8993fbd2 💫 Auto hide or show call controls 2024-08-02 18:09:07 +08:00
c88fcc84da Show call participants 2024-08-02 17:14:23 +08:00
11fb79623e Attachment can link exists things
 Optimize upload progress
2024-08-02 15:49:32 +08:00
98cc313a91 💫 Optimize chat event list animation 2024-08-02 14:14:09 +08:00
bc3401a897 🐛 Fix post item color mismatch 2024-08-02 05:10:10 +08:00
5b6a5d9046 🐛 Fix post popup color mismatch 2024-08-02 05:04:31 +08:00
6cbd78e836 💫 Optimize post editor transition 2024-08-02 04:59:35 +08:00
aefcbad02f 💫 Better animated post list 2024-08-02 04:42:38 +08:00
70617be687 💫 Animated chat 2024-08-02 04:24:12 +08:00
cccb3d5c16 🐛 Fix post won't refresh after post 2024-08-02 01:00:31 +08:00
a0a3a8d182 DM message last preview 2024-08-02 00:54:19 +08:00
c6b2ef8459 💄 Better about 2024-08-02 00:41:12 +08:00
34a2fe3988 Move about page link from account to settings 2024-08-02 00:29:51 +08:00
0a5604d0ff Crop image in personalize 2024-08-02 00:12:16 +08:00
5e754ad233 💫 About page icon will rotate 2024-08-01 23:51:03 +08:00
5b9c92e4d3 Crop image 2024-08-01 23:44:07 +08:00
b2a6ca7244 Improve attachments queue performance 2024-08-01 23:10:19 +08:00
27c60fc8cb Block user action when attachments isn't ready 2024-08-01 22:36:00 +08:00
103 changed files with 2343 additions and 843 deletions

View File

@@ -70,6 +70,12 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:name="com.yalantis.ucrop.UCropActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar"/>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 360 KiB

View File

@@ -113,7 +113,7 @@ PODS:
- TOCropViewController (~> 2.7.4)
- image_picker_ios (0.0.1):
- Flutter
- livekit_client (2.2.2):
- livekit_client (2.2.3):
- Flutter
- WebRTC-SDK (= 125.6422.04)
- media_kit_libs_ios_video (1.0.4):
@@ -145,7 +145,7 @@ PODS:
- SDWebImage/Core (= 5.19.4)
- SDWebImage/Core (5.19.4)
- Sentry/HybridSDK (8.32.0)
- sentry_flutter (8.5.0):
- sentry_flutter (8.6.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.32.0)
@@ -295,7 +295,7 @@ SPEC CHECKSUMS:
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
image_cropper: 37d40f62177c101ff4c164906d259ea2c3aa70cf
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
livekit_client: c767049a635d5b6d43de3273dca3c439b8a6e970
livekit_client: bad83a7776a41abc42e1f26d903eeac9164c8a9f
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
@@ -309,7 +309,7 @@ SPEC CHECKSUMS:
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
Sentry: 96ae1dcdf01a644bc3a3b1dc279cecaf48a833fb
sentry_flutter: f1d86adcb93a959bc47a40d8d55059bdf7569bc5
sentry_flutter: 090351ce1ff5f96a4b33ef9455b7e3b28185387d
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 815 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:solian/exts.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart';
@@ -24,6 +26,7 @@ class BootstrapperShell extends StatefulWidget {
class _BootstrapperShellState extends State<BootstrapperShell> {
bool _isBusy = true;
bool _isErrored = false;
bool _isDismissable = true;
String? _subtitle;
Color get _unFocusColor =>
@@ -38,6 +41,32 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
await context.read<ThemeSwitcher>().restoreTheme();
},
),
(
label: 'bsCheckForUpdate',
action: () async {
if (PlatformInfo.isWeb) return;
try {
final info = await PackageInfo.fromPlatform();
final localVersionString = '${info.version}+${info.buildNumber}';
final resp = await GetConnect().get(
'https://git.solsynth.dev/api/v1/repos/hydrogen/solian/tags?limit=1',
);
if (resp.body[0]['name'] != localVersionString) {
setState(() {
_isErrored = true;
_subtitle = PlatformInfo.isIOS || PlatformInfo.isMacOS
? 'bsCheckForUpdateDescApple'.tr
: 'bsCheckForUpdateDescCommon'.tr;
});
}
} catch (e) {
setState(() {
_isErrored = true;
_subtitle = 'bsCheckForUpdateFailed'.tr;
});
}
},
),
(
label: 'bsCheckingServer',
action: () async {
@@ -47,12 +76,14 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
setState(() {
_isErrored = true;
_subtitle = 'bsCheckingServerDown'.tr;
_isDismissable = false;
});
throw Exception('unable connect to server');
} else if (resp.statusCode == null) {
setState(() {
_isErrored = true;
_subtitle = 'bsCheckingServerFail'.tr;
_isDismissable = false;
});
throw Exception('unable connect to server');
}
@@ -81,13 +112,15 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
label: 'bsPreparingData',
action: () async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isTrue) {
await Future.wait([
Get.find<RealmProvider>().refreshAvailableRealms(),
await Future.wait([
Get.find<StickerProvider>().refreshAvailableStickers(),
if (auth.isAuthorized.isTrue)
Get.find<ChannelProvider>().refreshAvailableChannel(),
Get.find<RelationshipProvider>().refreshFriendList(),
]);
}
if (auth.isAuthorized.isTrue)
Get.find<RelationshipProvider>().refreshRelativeList(),
if (auth.isAuthorized.isTrue)
Get.find<RealmProvider>().refreshAvailableRealms(),
]);
},
),
(
@@ -111,7 +144,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
try {
for (var idx = 0; idx < _periods.length; idx++) {
await _periods[idx].action();
if (_isErrored) break;
if (_isErrored && !_isDismissable) break;
if (_periodCursor < _periods.length - 1) {
setState(() => _periodCursor++);
}
@@ -140,17 +173,20 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
height: 280,
child: Align(
alignment: Alignment.bottomCenter,
child: Image.asset('assets/logo.png', width: 80, height: 80)
.animate(onPlay: (c) => c.repeat())
.rotate(duration: 850.ms, curve: Curves.easeInOut),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Image.asset('assets/logo.png', width: 80, height: 80),
),
),
),
GestureDetector(
child: Column(
children: [
if (_isErrored)
const Icon(Icons.cancel, size: 24)
else
if (_isErrored && !_isDismissable && !_isBusy)
const Icon(Icons.cancel, size: 24),
if (_isErrored && _isDismissable && !_isBusy)
const Icon(Icons.warning, size: 24),
if ((_isErrored && _isDismissable && _isBusy) || _isBusy)
const SizedBox(
width: 24,
height: 24,
@@ -161,15 +197,24 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
maxWidth: 280,
child: Column(
children: [
Text(
_subtitle ??
'${_periods[_periodCursor].label.tr} (${_periodCursor + 1}/${_periods.length})',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
if (_subtitle == null)
Text(
'${_periods[_periodCursor].label.tr} (${_periodCursor + 1}/${_periods.length})',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
),
),
),
if (_subtitle != null)
Text(
_subtitle!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
),
).paddingOnly(bottom: 4),
Text(
'2024 © Solsynth LLC',
textAlign: TextAlign.center,
@@ -185,12 +230,19 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
),
onTap: () {
if (_isBusy) return;
setState(() {
_isBusy = true;
_isErrored = false;
_periodCursor = 0;
});
_runPeriods();
if (_isDismissable) {
setState(() {
_isBusy = false;
_isErrored = false;
});
} else {
setState(() {
_isBusy = true;
_isErrored = false;
_periodCursor = 0;
});
_runPeriods();
}
},
)
],

View File

@@ -16,7 +16,7 @@ class ChatEventController {
Channel? channel;
String? scope;
initialize() async {
Future<void> initialize() async {
if (!PlatformInfo.isWeb) {
database = await createHistoryDb();
}
@@ -131,6 +131,8 @@ class ChatEventController {
}
insertEvent(LocalEvent entry) {
if (entry.channelId != channel?.id) return;
final idx = currentEvents.indexWhere((x) => x.data.uuid == entry.data.uuid);
if (idx != -1) {
currentEvents[idx] = entry;

View File

@@ -115,10 +115,10 @@ class PostEditorController extends GetxController {
builder: (context) => AttachmentEditorPopup(
usage: 'i.attachment',
initialAttachments: attachments,
onAdd: (value) {
onAdd: (int value) {
attachments.add(value);
},
onRemove: (value) {
onRemove: (int value) {
attachments.remove(value);
},
),

View File

@@ -30,6 +30,23 @@ extension SolianExtenions on BuildContext {
);
}
Future<void> showInfoDialog(String title, body) {
return showDialog<void>(
useRootNavigator: true,
context: this,
builder: (ctx) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text('okay'.tr),
)
],
),
);
}
Future<void> showErrorDialog(dynamic exception) {
var stack = StackTrace.current;
var stackTrace = '$stack';

View File

@@ -11,6 +11,7 @@ import 'package:solian/bootstrapper.dart';
import 'package:solian/firebase_options.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/providers/auth.dart';
@@ -120,6 +121,7 @@ class SolianApp extends StatelessWidget {
Get.lazyPut(() => AuthProvider());
Get.lazyPut(() => RelationshipProvider());
Get.lazyPut(() => PostProvider());
Get.lazyPut(() => StickerProvider());
Get.lazyPut(() => AttachmentProvider());
Get.lazyPut(() => WebSocketProvider());
Get.lazyPut(() => StatusProvider());

View File

@@ -10,6 +10,7 @@ class Call {
String externalId;
int founderId;
int channelId;
List<dynamic> participants;
Channel channel;
Call({
@@ -21,6 +22,7 @@ class Call {
required this.externalId,
required this.founderId,
required this.channelId,
required this.participants,
required this.channel,
});
@@ -34,6 +36,7 @@ class Call {
externalId: json['external_id'],
founderId: json['founder_id'],
channelId: json['channel_id'],
participants: json['participants'] ?? List.empty(),
channel: Channel.fromJson(json['channel']),
);
@@ -46,6 +49,7 @@ class Call {
'external_id': externalId,
'founder_id': founderId,
'channel_id': channelId,
'participants': participants,
'channel': channel.toJson(),
};
}
@@ -63,6 +67,7 @@ class ParticipantTrack {
{required this.participant,
required this.videoTrack,
required this.isScreenShare});
VideoTrack? videoTrack;
Participant participant;
bool isScreenShare;

122
lib/models/stickers.dart Normal file
View File

@@ -0,0 +1,122 @@
import 'package:solian/models/account.dart';
import 'package:solian/models/attachment.dart';
class Sticker {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String alias;
String name;
int attachmentId;
Attachment attachment;
int packId;
StickerPack? pack;
int accountId;
Account account;
Sticker({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.alias,
required this.name,
required this.attachmentId,
required this.attachment,
required this.packId,
required this.pack,
required this.accountId,
required this.account,
});
factory Sticker.fromJson(Map<String, dynamic> json) => Sticker(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at'])
: json['deleted_at'],
alias: json['alias'],
name: json['name'],
attachmentId: json['attachment_id'],
attachment: Attachment.fromJson(json['attachment']),
packId: json['pack_id'],
pack: json['pack'] != null ? StickerPack.fromJson(json['pack']) : null,
accountId: json['account_id'],
account: Account.fromJson(json['account']),
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(),
'alias': alias,
'name': name,
'attachment_id': attachmentId,
'attachment': attachment.toJson(),
'pack_id': packId,
'account_id': accountId,
'account': account.toJson(),
};
}
class StickerPack {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String prefix;
String name;
String description;
List<Sticker>? stickers;
int accountId;
Account account;
StickerPack({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.prefix,
required this.name,
required this.description,
required this.stickers,
required this.accountId,
required this.account,
});
factory StickerPack.fromJson(Map<String, dynamic> json) => StickerPack(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at'])
: json['deleted_at'],
prefix: json['prefix'],
name: json['name'],
description: json['description'],
stickers: json['stickers'] == null
? []
: List<Sticker>.from(
json['stickers']!.map((x) => Sticker.fromJson(x))),
accountId: json['account_id'],
account: Account.fromJson(json['account']),
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(),
'prefix': prefix,
'name': name,
'description': description,
'stickers': stickers == null
? []
: List<dynamic>.from(stickers!.map((x) => x.toJson())),
'account_id': accountId,
'account': account.toJson(),
};
}

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
@@ -13,6 +14,7 @@ class AttachmentUploadTask {
double progress = 0;
bool isUploading = false;
bool isCompleted = false;
dynamic error;
AttachmentUploadTask({
required this.file,
@@ -26,22 +28,51 @@ class AttachmentUploaderController extends GetxController {
RxDouble progressOfUpload = 0.0.obs;
RxList<AttachmentUploadTask> queueOfUpload = RxList.empty(growable: true);
Timer? _progressSyncTimer;
double _progressOfUpload = 0.0;
void _syncProgress() {
progressOfUpload.value = _progressOfUpload;
queueOfUpload.refresh();
}
void _startProgressSyncTimer() {
if (_progressSyncTimer != null) {
_progressSyncTimer!.cancel();
}
_progressSyncTimer = Timer.periodic(
const Duration(milliseconds: 500),
(_) => _syncProgress(),
);
}
void _stopProgressSyncTimer() {
if (_progressSyncTimer == null) return;
_progressSyncTimer!.cancel();
_progressSyncTimer = null;
}
void enqueueTask(AttachmentUploadTask task) {
if (isUploading.value) throw Exception('uploading blocked');
queueOfUpload.add(task);
}
void enqueueTaskBatch(Iterable<AttachmentUploadTask> tasks) {
if (isUploading.value) throw Exception('uploading blocked');
queueOfUpload.addAll(tasks);
}
void dequeueTask(AttachmentUploadTask task) {
if (isUploading.value) throw Exception('uploading blocked');
queueOfUpload.remove(task);
}
Future<Attachment> performSingleTask(int queueIndex) async {
Future<Attachment?> performSingleTask(int queueIndex) async {
isUploading.value = true;
progressOfUpload.value = 0;
_startProgressSyncTimer();
queueOfUpload[queueIndex].isUploading = true;
queueOfUpload.refresh();
final task = queueOfUpload[queueIndex];
final result = await _rawUploadAttachment(
@@ -51,13 +82,19 @@ class AttachmentUploaderController extends GetxController {
null,
onProgress: (value) {
queueOfUpload[queueIndex].progress = value;
queueOfUpload.refresh();
progressOfUpload.value = value;
_progressOfUpload = value;
},
onError: (err) {
queueOfUpload[queueIndex].error = err;
queueOfUpload[queueIndex].isUploading = false;
},
);
queueOfUpload.removeAt(queueIndex);
queueOfUpload.refresh();
if (queueOfUpload[queueIndex].error == null) {
queueOfUpload.removeAt(queueIndex);
}
_stopProgressSyncTimer();
_syncProgress();
isUploading.value = false;
@@ -70,9 +107,14 @@ class AttachmentUploaderController extends GetxController {
isUploading.value = true;
progressOfUpload.value = 0;
_startProgressSyncTimer();
for (var idx = 0; idx < queueOfUpload.length; idx++) {
if (queueOfUpload[idx].isUploading || queueOfUpload[idx].error != null) {
continue;
}
queueOfUpload[idx].isUploading = true;
queueOfUpload.refresh();
final task = queueOfUpload[idx];
final result = await _rawUploadAttachment(
@@ -82,20 +124,25 @@ class AttachmentUploaderController extends GetxController {
null,
onProgress: (value) {
queueOfUpload[idx].progress = value;
queueOfUpload.refresh();
progressOfUpload.value = (idx + value) / queueOfUpload.length;
_progressOfUpload = (idx + value) / queueOfUpload.length;
},
onError: (err) {
queueOfUpload[idx].error = err;
queueOfUpload[idx].isUploading = false;
},
);
progressOfUpload.value = (idx + 1) / queueOfUpload.length;
onData(result);
_progressOfUpload = (idx + 1) / queueOfUpload.length;
if (result != null) onData(result);
queueOfUpload[idx].isUploading = false;
queueOfUpload[idx].isCompleted = false;
queueOfUpload.refresh();
queueOfUpload[idx].isCompleted = true;
}
queueOfUpload.clear();
queueOfUpload.refresh();
queueOfUpload.value =
queueOfUpload.where((x) => x.error == null).toList(growable: true);
_stopProgressSyncTimer();
_syncProgress();
isUploading.value = false;
}
@@ -104,7 +151,7 @@ class AttachmentUploaderController extends GetxController {
String path,
String usage,
Map<String, dynamic>? metadata,
Function(Attachment) callback,
Function(Attachment?) callback,
) async {
if (isUploading.value) throw Exception('uploading blocked');
@@ -122,7 +169,7 @@ class AttachmentUploaderController extends GetxController {
callback(result);
}
Future<Attachment> uploadAttachment(
Future<Attachment?> uploadAttachment(
Uint8List data,
String path,
String usage,
@@ -144,22 +191,24 @@ class AttachmentUploaderController extends GetxController {
return result;
}
Future<Attachment> _rawUploadAttachment(
Future<Attachment?> _rawUploadAttachment(
Uint8List data, String path, String usage, Map<String, dynamic>? metadata,
{Function(double)? onProgress}) async {
{Function(double)? onProgress, Function(dynamic err)? onError}) async {
final AttachmentProvider provider = Get.find();
try {
final resp = await provider.createAttachment(
final result = await provider.createAttachment(
data,
path,
usage,
metadata,
onProgress: onProgress,
);
var result = Attachment.fromJson(resp.body);
return result;
} catch (err) {
rethrow;
if (onError != null) {
onError(err);
}
return null;
}
}
}

View File

@@ -16,6 +16,7 @@ class ChatCallProvider extends GetxController {
RxBool isReady = false.obs;
RxBool isMounted = false.obs;
RxBool isInitialized = false.obs;
String? token;
String? endpoint;
@@ -151,6 +152,8 @@ class ChatCallProvider extends GetxController {
void onRoomDidUpdate() => sortParticipants();
void setupRoom() {
if(isInitialized.value) return;
sortParticipants();
room.addListener(onRoomDidUpdate);
WidgetsBindingCompatible.instance?.addPostFrameCallback(
@@ -160,6 +163,8 @@ class ChatCallProvider extends GetxController {
if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true);
}
isInitialized.value = true;
}
void setupRoomListeners({
@@ -362,6 +367,7 @@ class ChatCallProvider extends GetxController {
void disposeRoom() {
isMounted.value = false;
isInitialized.value = false;
current.value = null;
channel.value = null;
room.removeListener(onRoomDidUpdate);

View File

@@ -4,8 +4,10 @@ import 'dart:typed_data';
import 'package:get/get.dart';
import 'package:path/path.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
import 'package:dio/dio.dart' as dio;
class AttachmentProvider extends GetConnect {
static Map<String, String> mimetypeOverrides = {
@@ -20,6 +22,50 @@ class AttachmentProvider extends GetConnect {
final Map<int, Attachment> _cachedResponses = {};
Future<List<Attachment?>> listMetadata(
List<int> id, {
noCache = false,
}) async {
if (id.isEmpty) return List.empty();
List<Attachment?> result = List.filled(id.length, null);
List<int> pendingQuery = List.empty(growable: true);
if (!noCache) {
for (var idx = 0; idx < id.length; idx++) {
if (_cachedResponses.containsKey(id[idx])) {
result[idx] = _cachedResponses[id[idx]];
} else {
pendingQuery.add(id[idx]);
}
}
}
final resp = await get(
'/attachments?take=${pendingQuery.length}&id=${pendingQuery.join(',')}',
);
if (resp.statusCode != 200) return result;
final rawOut = PaginationResult.fromJson(resp.body);
if (rawOut.data == null) return result;
final List<Attachment> out =
rawOut.data!.map((x) => Attachment.fromJson(x)).toList();
for (final item in out) {
if (item.destination != 0 && item.isAnalyzed) {
_cachedResponses[item.id] = item;
}
}
for (var i = 0; i < out.length; i++) {
for (var j = 0; j < id.length; j++) {
if (out[i].id == id[j]) {
result[j] = out[i];
}
}
}
return result;
}
Future<Attachment?> getMetadata(int id, {noCache = false}) async {
if (!noCache && _cachedResponses.containsKey(id)) {
return _cachedResponses[id]!;
@@ -37,18 +83,14 @@ class AttachmentProvider extends GetConnect {
return null;
}
Future<Response> createAttachment(
Future<Attachment> createAttachment(
Uint8List data, String path, String usage, Map<String, dynamic>? metadata,
{Function(double)? onProgress}) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw Exception('unauthorized');
final client = auth.configureClient(
'files',
timeout: const Duration(minutes: 3),
);
final filePayload = MultipartFile(data, filename: basename(path));
final filePayload =
dio.MultipartFile.fromBytes(data, filename: basename(path));
final fileAlt = basename(path).contains('.')
? basename(path).substring(0, basename(path).lastIndexOf('.'))
: basename(path);
@@ -61,25 +103,30 @@ class AttachmentProvider extends GetConnect {
if (mimetypeOverrides.keys.contains(fileExt)) {
mimetypeOverride = mimetypeOverrides[fileExt];
}
final payload = FormData({
final payload = dio.FormData.fromMap({
'alt': fileAlt,
'file': filePayload,
'usage': usage,
if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
'metadata': jsonEncode(metadata),
});
final resp = await client.post(
final resp = await dio.Dio(
dio.BaseOptions(
baseUrl: ServiceFinder.buildUrl('files', null),
headers: {'Authorization': 'Bearer ${auth.credentials!.accessToken}'},
),
).post(
'/attachments',
payload,
uploadProgress: (progress) {
if (onProgress != null) onProgress(progress);
data: payload,
onSendProgress: (count, total) {
if (onProgress != null) onProgress(count / total);
},
);
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
throw Exception(resp.data);
}
return resp;
return Attachment.fromJson(resp.data);
}
Future<Response> updateAttachment(

View File

@@ -4,15 +4,19 @@ import 'package:solian/models/relations.dart';
import 'package:solian/providers/auth.dart';
class RelationshipProvider extends GetxController {
final RxInt friendRequestCount = 0.obs;
final RxList<Relationship> _friends = RxList.empty(growable: true);
Future<void> refreshFriendList() async {
final resp = await listRelationWithStatus(1);
_friends.value = resp.body
Future<void> refreshRelativeList() async {
final resp = await listRelation();
final List<Relationship> result = resp.body
.map((e) => Relationship.fromJson(e))
.toList()
.cast<Relationship>();
_friends.value = result.where((x) => x.status == 1).toList();
_friends.refresh();
friendRequestCount.value = result.where((x) => x.status == 0).length;
}
bool hasFriend(Account account) {

View File

@@ -0,0 +1,38 @@
import 'package:get/get.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/stickers.dart';
import 'package:solian/services.dart';
class StickerProvider extends GetxController {
final RxMap<String, String> aliasImageMapping = RxMap();
final RxMap<String, List<Sticker>> availableStickers = RxMap();
Future<void> refreshAvailableStickers() async {
final client = ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/manifest?take=100',
);
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final out = result.data?.map((e) => StickerPack.fromJson(e)).toList();
if (out == null) return;
for (final pack in out) {
for (final sticker in (pack.stickers ?? List<Sticker>.empty())) {
sticker.pack = pack;
final imageUrl = ServiceFinder.buildUrl(
'files',
'/attachments/${sticker.attachmentId}',
);
aliasImageMapping['${pack.prefix}${sticker.alias}'.camelCase!] =
imageUrl;
if (availableStickers[pack.prefix] == null) {
availableStickers[pack.prefix] = List.empty(growable: true);
}
availableStickers[pack.prefix]!.add(sticker);
}
}
}
availableStickers.refresh();
}
}

View File

@@ -1,3 +1,5 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/screens/about.dart';
@@ -5,6 +7,7 @@ import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/personalize.dart';
import 'package:solian/screens/account/profile_page.dart';
import 'package:solian/screens/account/stickers.dart';
import 'package:solian/screens/channel/channel_chat.dart';
import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/screens/channel/channel_organize.dart';
@@ -93,14 +96,26 @@ abstract class AppRouter {
GoRoute(
path: '/posts/editor',
name: 'postEditor',
builder: (context, state) {
pageBuilder: (context, state) {
final arguments = state.extra as PostPublishArguments?;
return PostPublishScreen(
edit: arguments?.edit,
reply: arguments?.reply,
repost: arguments?.repost,
realm: arguments?.realm,
mode: int.tryParse(state.uri.queryParameters['mode'] ?? '0') ?? 0,
return CustomTransitionPage(
child: PostPublishScreen(
edit: arguments?.edit,
reply: arguments?.reply,
repost: arguments?.repost,
realm: arguments?.realm,
postListController: arguments?.postListController,
mode: int.tryParse(state.uri.queryParameters['mode'] ?? '0') ?? 0,
),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeThroughTransition(
fillColor: Theme.of(context).scaffoldBackgroundColor,
animation: animation,
secondaryAnimation: secondaryAnimation,
child: child,
);
},
);
},
),
@@ -212,6 +227,14 @@ abstract class AppRouter {
name: 'accountFriend',
builder: (context, state) => const FriendScreen(),
),
GoRoute(
path: '/account/stickers',
name: 'accountStickers',
builder: (context, state) => TitleShell(
state: state,
child: const StickerScreen(),
),
),
GoRoute(
path: '/account/personalize',
name: 'accountPersonalize',

View File

@@ -17,7 +17,11 @@ class AboutScreen extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset('assets/logo.png', width: 64, height: 64),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Image.asset('assets/logo.png', width: 120, height: 120),
),
const SizedBox(height: 8),
Text(
'Solian',
style: Theme.of(context).textTheme.headlineMedium,
@@ -44,17 +48,28 @@ class AboutScreen extends StatelessWidget {
const SizedBox(height: 16),
TextButton(
style: denseButtonStyle,
child: const Text('More Information'),
onPressed: () {
launchUrlString('https://solsynth.dev/products/solar-network');
child: const Text('App Details'),
onPressed: () async {
final info = await PackageInfo.fromPlatform();
showAboutDialog(
context: context,
applicationVersion: '${info.version} (${info.buildNumber})',
applicationLegalese:
'The Solar Network App is an intuitive and self-hostable social network and computing platform. Experience the freedom of a user-friendly design that empowers you to create and connect with communities on your own terms. Embrace the future of social networking with a platform that prioritizes your independence and privacy.',
applicationIcon: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Image.asset('assets/logo.png', width: 60, height: 60),
),
);
},
),
TextButton(
style: denseButtonStyle,
child: const Text('Project Website'),
onPressed: () {
launchUrlString('https://solsynth.dev');
launchUrlString('https://solsynth.dev/products/solar-network');
},
child: const Text('Official Website'),
),
const SizedBox(height: 16),
const Text(

View File

@@ -3,11 +3,13 @@ import 'package:get/get.dart';
import 'package:solian/models/account.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/auth/signin.dart';
import 'package:solian/screens/auth/signup.dart';
import 'package:solian/widgets/account/account_heading.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:badges/badges.dart' as badges;
class AccountScreen extends StatefulWidget {
const AccountScreen({super.key});
@@ -23,10 +25,32 @@ class _AccountScreenState extends State<AccountScreen> {
(
const Icon(Icons.color_lens),
'accountPersonalize'.tr,
'accountPersonalize'
'accountPersonalize',
),
(
Obx(() {
final RelationshipProvider relations = Get.find();
return badges.Badge(
badgeContent: Text(
relations.friendRequestCount.value.toString(),
style: const TextStyle(color: Colors.white),
),
showBadge: relations.friendRequestCount.value > 0,
position: badges.BadgePosition.topEnd(
top: -12,
end: -8,
),
child: const Icon(Icons.diversity_1),
);
}),
'accountFriend'.tr,
'accountFriend',
),
(
const Icon(Icons.emoji_symbols),
'accountStickers'.tr,
'accountStickers',
),
(const Icon(Icons.diversity_1), 'accountFriend'.tr, 'accountFriend'),
(const Icon(Icons.info_outline), 'about'.tr, 'about'),
];
final AuthProvider auth = Get.find();
@@ -41,7 +65,10 @@ class _AccountScreenState extends State<AccountScreen> {
mainAxisSize: MainAxisSize.min,
children: [
ActionCard(
icon: const Icon(Icons.login, color: Colors.white),
icon: Icon(
Icons.login,
color: Theme.of(context).colorScheme.onPrimary,
),
title: 'signin'.tr,
caption: 'signinCaption'.tr,
onTap: () {
@@ -59,7 +86,10 @@ class _AccountScreenState extends State<AccountScreen> {
},
),
ActionCard(
icon: const Icon(Icons.add, color: Colors.white),
icon: Icon(
Icons.add,
color: Theme.of(context).colorScheme.onPrimary,
),
title: 'signup'.tr,
caption: 'signupCaption'.tr,
onTap: () {
@@ -184,7 +214,7 @@ class ActionCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundColor: Colors.indigo,
backgroundColor: Theme.of(context).colorScheme.primary,
child: icon,
).paddingOnly(bottom: 12),
Text(

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/relations.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/relative_list.dart';
class FriendScreen extends StatefulWidget {
@@ -21,15 +21,15 @@ class _FriendScreenState extends State<FriendScreen>
List<Relationship> _relations = List.empty();
List<Relationship> filterByStatus(int status) {
List<Relationship> _filterByStatus(int status) {
return _relations.where((x) => x.status == status).toList();
}
Future<void> loadRelations() async {
Future<void> _loadRelations() async {
setState(() => _isBusy = true);
final RelationshipProvider provider = Get.find();
final resp = await provider.listRelation();
final RelationshipProvider relations = Get.find();
final resp = await relations.listRelation();
setState(() {
_relations = resp.body
@@ -38,9 +38,12 @@ class _FriendScreenState extends State<FriendScreen>
.cast<Relationship>();
_isBusy = false;
});
relations.friendRequestCount.value =
_relations.where((x) => x.status == 0).length;
}
void promptAddFriend() async {
void _promptAddFriend() async {
final RelationshipProvider provider = Get.find();
final controller = TextEditingController();
@@ -104,8 +107,8 @@ class _FriendScreenState extends State<FriendScreen>
super.initState();
_tabController = TabController(length: 3, vsync: this);
loadRelations().then((_) {
if (filterByStatus(0).isEmpty) {
_loadRelations().then((_) {
if (_filterByStatus(0).isEmpty) {
_tabController.animateTo(1);
}
});
@@ -119,6 +122,19 @@ class _FriendScreenState extends State<FriendScreen>
appBar: AppBar(
centerTitle: false,
title: Text('accountFriend'.tr),
actions: [
if (_isBusy)
SizedBox(
height: 48,
width: 48,
child: const CircularProgressIndicator(
strokeWidth: 3,
).paddingAll(14),
),
SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
),
],
bottom: TabBar(
controller: _tabController,
tabs: const [
@@ -130,52 +146,40 @@ class _FriendScreenState extends State<FriendScreen>
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => promptAddFriend(),
onPressed: () => _promptAddFriend(),
),
body: TabBarView(
controller: _tabController,
children: [
RefreshIndicator(
onRefresh: () => loadRelations(),
onRefresh: () => _loadRelations(),
child: CustomScrollView(
slivers: [
if (_isBusy)
SliverToBoxAdapter(
child: const LinearProgressIndicator().animate().scaleX(),
),
SilverRelativeList(
items: filterByStatus(0),
onUpdate: () => loadRelations(),
items: _filterByStatus(0),
onUpdate: () => _loadRelations(),
),
],
),
),
RefreshIndicator(
onRefresh: () => loadRelations(),
onRefresh: () => _loadRelations(),
child: CustomScrollView(
slivers: [
if (_isBusy)
SliverToBoxAdapter(
child: const LinearProgressIndicator().animate().scaleX(),
),
SilverRelativeList(
items: filterByStatus(1),
onUpdate: () => loadRelations(),
items: _filterByStatus(1),
onUpdate: () => _loadRelations(),
),
],
),
),
RefreshIndicator(
onRefresh: () => loadRelations(),
onRefresh: () => _loadRelations(),
child: CustomScrollView(
slivers: [
if (_isBusy)
SliverToBoxAdapter(
child: const LinearProgressIndicator().animate().scaleX(),
),
SilverRelativeList(
items: filterByStatus(3),
onUpdate: () => loadRelations(),
items: _filterByStatus(3),
onUpdate: () => _loadRelations(),
),
],
),

View File

@@ -3,9 +3,11 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart';
import 'package:solian/services.dart';
@@ -50,26 +52,24 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
}
void _syncWidget() async {
setState(() => _isBusy = true);
_isBusy = true;
final AuthProvider auth = Get.find();
final prof = auth.userProfile.value!;
setState(() {
_usernameController.text = prof['name'];
_nicknameController.text = prof['nick'];
_descriptionController.text = prof['description'];
_firstNameController.text = prof['profile']['first_name'];
_lastNameController.text = prof['profile']['last_name'];
_avatar = prof['avatar'];
_banner = prof['banner'];
if (prof['profile']['birthday'] != null) {
_birthday = DateTime.parse(prof['profile']['birthday']);
_birthdayController.text =
DateFormat('yyyy-MM-dd').format(_birthday!.toLocal());
}
_usernameController.text = prof['name'];
_nicknameController.text = prof['nick'];
_descriptionController.text = prof['description'];
_firstNameController.text = prof['profile']['first_name'];
_lastNameController.text = prof['profile']['last_name'];
_avatar = prof['avatar'];
_banner = prof['banner'];
if (prof['profile']['birthday'] != null) {
_birthday = DateTime.parse(prof['profile']['birthday']);
_birthdayController.text =
DateFormat('yyyy-MM-dd').format(_birthday!.toLocal());
}
_isBusy = false;
});
_isBusy = false;
}
Future<void> _editImage(String position) async {
@@ -79,15 +79,46 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return;
CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: image.path,
uiSettings: [
AndroidUiSettings(
toolbarTitle: 'cropImage'.tr,
toolbarColor: Theme.of(context).colorScheme.primary,
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
aspectRatioPresets: [
if (position == 'avatar') CropAspectRatioPreset.square,
if (position == 'banner') _BannerCropAspectRatioPreset(),
],
),
IOSUiSettings(
title: 'cropImage'.tr,
aspectRatioPresets: [
if (position == 'avatar') CropAspectRatioPreset.square,
if (position == 'banner') _BannerCropAspectRatioPreset(),
],
),
WebUiSettings(
context: context,
),
],
);
if (croppedFile == null) return;
final file = File(croppedFile.path);
setState(() => _isBusy = true);
final AttachmentProvider provider = Get.find();
Response? attachResp;
Attachment? attachResult;
try {
final file = File(image.path);
attachResp = await provider.createAttachment(
await file.readAsBytes(), file.path, 'p.$position', null);
attachResult = await provider.createAttachment(
await file.readAsBytes(),
file.path,
'p.$position',
null,
);
} catch (e) {
setState(() => _isBusy = false);
context.showErrorDialog(e);
@@ -98,7 +129,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
final resp = await client.put(
'/users/me/$position',
{'attachment': attachResp.body['id']},
{'attachment': attachResult.id},
);
if (resp.statusCode == 200) {
_syncWidget();
@@ -142,8 +173,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () => _syncWidget());
_syncWidget();
}
@override
@@ -322,3 +352,11 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
super.dispose();
}
}
class _BannerCropAspectRatioPreset extends CropAspectRatioPresetData {
@override
(int, int)? get data => (16, 7);
@override
String get name => '16x7';
}

View File

@@ -40,7 +40,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
List<Post> _pinnedPosts = List.empty();
int _totalUpvote = 0, _totalDownvote = 0;
Future<void> getUserinfo() async {
Future<void> _getUserinfo() async {
setState(() => _isBusy = true);
var client = ServiceFinder.configureClient('auth');
@@ -114,7 +114,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
}
});
getUserinfo();
_getUserinfo();
getPinnedPosts();
}
@@ -189,8 +189,11 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
: () async {
setState(() => _isMakingFriend = true);
try {
await _relationshipProvider.makeFriend(widget.name);
context.showSnackbar('accountFriendRequestSent'.tr);
await _relationshipProvider
.makeFriend(widget.name);
context.showSnackbar(
'accountFriendRequestSent'.tr,
);
} catch (e) {
context.showErrorDialog(e);
} finally {
@@ -274,6 +277,8 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
color:
Theme.of(context).colorScheme.surfaceContainerLow,
child: PostListEntryWidget(
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerLow,
item: element,
isClickable: true,
isNestedClickable: true,

View File

@@ -0,0 +1,181 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/stickers.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/stickers/sticker_uploader.dart';
class StickerScreen extends StatefulWidget {
const StickerScreen({super.key});
@override
State<StickerScreen> createState() => _StickerScreenState();
}
class _StickerScreenState extends State<StickerScreen> {
final PagingController<int, StickerPack> _pagingController =
PagingController(firstPageKey: 0);
Future<bool> _promptDelete(Sticker item, String prefix) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return false;
final confirm = await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('stickerDeletionConfirm'.tr),
content: Text(
'stickerDeletionConfirmCaption'.trParams({
'name': ':${'$prefix${item.alias}'.camelCase}:',
}),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('confirm'.tr),
),
],
),
);
if (confirm != true) return false;
final client = auth.configureClient('files');
final resp = await client.delete('/stickers/${item.id}');
return resp.statusCode == 200;
}
Future<bool?> _promptUploadSticker({Sticker? edit}) {
return showDialog(
context: context,
builder: (context) => StickerUploadDialog(
edit: edit,
),
);
}
Widget _buildEmoteEntry(Sticker item, String prefix) {
final imageUrl = ServiceFinder.buildUrl(
'files',
'/attachments/${item.attachmentId}',
);
return ListTile(
title: Text(item.name),
subtitle: Text(':${'$prefix${item.alias}'.camelCase}:'),
contentPadding: const EdgeInsets.only(left: 16, right: 14),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_square),
onPressed: () {
_promptUploadSticker(edit: item).then((value) {
if (value == true) _pagingController.refresh();
});
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
_promptDelete(item, prefix).then((value) {
if (value == true) _pagingController.refresh();
});
},
),
],
),
leading: PlatformInfo.canCacheImage
? CachedNetworkImage(
imageUrl: imageUrl,
width: 28,
height: 28,
)
: Image.network(
imageUrl,
width: 28,
height: 28,
),
);
}
@override
void initState() {
final AuthProvider auth = Get.find();
final name = auth.userProfile.value!['name'];
_pagingController.addPageRequestListener((pageKey) async {
final client = ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/manifest?take=10&offset=$pageKey&author=$name',
);
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final out = result.data?.map((e) => StickerPack.fromJson(e)).toList();
if (out != null && result.data!.length >= 10) {
_pagingController.appendPage(out, pageKey + out.length);
} else if (out != null) {
_pagingController.appendLastPage(out);
}
} else {
_pagingController.error = resp.bodyString;
}
});
super.initState();
}
@override
void dispose() {
final StickerProvider sticker = Get.find();
sticker.refreshAvailableStickers();
_pagingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
_promptUploadSticker().then((value) {
if (value == true) _pagingController.refresh();
});
},
),
body: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView(
slivers: [
PagedSliverList<int, StickerPack>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (BuildContext context, item, int index) {
return ExpansionTile(
title: Text(item.name),
subtitle: Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
children: item.stickers
?.map((x) => _buildEmoteEntry(x, item.prefix))
.toList() ??
List.empty(),
);
},
),
),
],
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:async/async.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/providers/call.dart';
@@ -16,11 +17,26 @@ class CallScreen extends StatefulWidget {
State<CallScreen> createState() => _CallScreenState();
}
class _CallScreenState extends State<CallScreen> {
Timer? timer;
String currentDuration = '00:00:00';
class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Timer? _timer;
String _currentDuration = '00:00:00';
String parseDuration() {
int _layoutMode = 0;
bool _showControls = true;
CancelableOperation? _hideControlsOperation;
late final AnimationController _controlsAnimationController =
AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
late final Animation<double> _controlsAnimation = CurvedAnimation(
parent: _controlsAnimationController,
curve: Curves.fastOutSlowIn,
);
String _parseDuration() {
final ChatCallProvider provider = Get.find();
if (provider.current.value == null) return '00:00:00';
Duration duration =
@@ -34,9 +50,142 @@ class _CallScreenState extends State<CallScreen> {
return formattedTime;
}
void updateDuration() {
void _updateDuration() {
setState(() {
currentDuration = parseDuration();
_currentDuration = _parseDuration();
});
}
void _switchLayout() {
if (_layoutMode < 1) {
setState(() => _layoutMode++);
} else {
setState(() => _layoutMode = 0);
}
}
void _toggleControls() {
if (_showControls) {
setState(() => _showControls = false);
_controlsAnimationController.animateTo(0);
_hideControlsOperation?.cancel();
} else {
setState(() => _showControls = true);
_controlsAnimationController.animateTo(1);
_planAutoHideControls();
}
}
void _planAutoHideControls() {
_hideControlsOperation = CancelableOperation.fromFuture(
Future.delayed(const Duration(seconds: 3), () {
if (!mounted) return;
if (_showControls) _toggleControls();
}),
);
}
Widget _buildListLayout() {
final ChatCallProvider call = Get.find();
return Obx(
() => Stack(
children: [
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: call.focusTrack.value != null
? InteractiveParticipantWidget(
isFixedAvatar: false,
participant: call.focusTrack.value!,
onTap: () {},
)
: const SizedBox(),
),
Positioned(
left: 0,
right: 0,
top: 0,
child: SizedBox(
height: 128,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = call.participantTracks[index];
if (track.participant.sid ==
call.focusTrack.value?.participant.sid) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(top: 8, left: 8),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
isFixedAvatar: true,
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid !=
call.focusTrack.value?.participant.sid) {
call.changeFocusTrack(track);
}
},
),
),
);
},
),
),
),
],
),
);
}
Widget _buildGridLayout() {
final ChatCallProvider call = Get.find();
return LayoutBuilder(builder: (context, constraints) {
double screenWidth = constraints.maxWidth;
double screenHeight = constraints.maxHeight;
int columns = (math.sqrt(call.participantTracks.length)).ceil();
int rows = (call.participantTracks.length / columns).ceil();
double tileWidth = screenWidth / columns;
double tileHeight = screenHeight / rows;
return Obx(
() => GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
childAspectRatio: tileWidth / tileHeight,
),
itemCount: math.max(0, call.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = call.participantTracks[index];
return Padding(
padding: const EdgeInsets.all(16),
child: Card(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
participant: track,
onTap: () {
if (track.participant.sid !=
call.focusTrack.value?.participant.sid) {
call.changeFocusTrack(track);
}
},
),
),
),
);
},
),
);
});
}
@@ -45,7 +194,18 @@ class _CallScreenState extends State<CallScreen> {
Get.find<ChatCallProvider>().setupRoom();
super.initState();
timer = Timer.periodic(const Duration(seconds: 1), (_) => updateDuration());
_updateDuration();
_planAutoHideControls();
_timer = Timer.periodic(
const Duration(seconds: 1),
(_) => _updateDuration(),
);
}
@override
void dispose() {
_controlsAnimationController.dispose();
super.dispose();
}
@override
@@ -68,80 +228,71 @@ class _CallScreenState extends State<CallScreen> {
),
const TextSpan(text: '\n'),
TextSpan(
text: currentDuration,
text: _currentDuration,
style: Theme.of(context).textTheme.bodySmall,
),
]),
),
),
body: SafeArea(
child: Obx(
() => Stack(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
child: Column(
children: [
Column(
children: [
Expanded(
child: Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: provider.focusTrack.value != null
? InteractiveParticipantWidget(
isFixed: false,
participant: provider.focusTrack.value!,
onTap: () {},
)
: const SizedBox(),
),
),
if (provider.room.localParticipant != null)
ControlsWidget(
provider.room,
provider.room.localParticipant!,
),
],
),
Positioned(
left: 0,
right: 0,
top: 0,
SizeTransition(
sizeFactor: _controlsAnimation,
axis: Axis.vertical,
child: SizedBox(
height: 128,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: math.max(0, provider.participantTracks.length),
itemBuilder: (BuildContext context, int index) {
final track = provider.participantTracks[index];
if (track.participant.sid ==
provider.focusTrack.value?.participant.sid) {
return Container();
width: MediaQuery.of(context).size.width,
height: 64,
child: Row(
children: [
const Expanded(child: SizedBox()),
IconButton(
icon: _layoutMode == 0
? const Icon(Icons.view_list)
: const Icon(Icons.grid_view),
onPressed: () {
_switchLayout();
},
),
],
).paddingSymmetric(horizontal: 10),
),
),
Expanded(
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
elevation: 2,
child: Builder(
builder: (context) {
switch (_layoutMode) {
case 1:
return _buildGridLayout();
default:
return _buildListLayout();
}
return Padding(
padding: const EdgeInsets.only(top: 8, left: 8),
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(8)),
child: InteractiveParticipantWidget(
isFixed: true,
width: 120,
height: 120,
color: Theme.of(context).cardColor,
participant: track,
onTap: () {
if (track.participant.sid !=
provider
.focusTrack.value?.participant.sid) {
provider.changeFocusTrack(track);
}
},
),
),
);
},
),
),
),
if (provider.room.localParticipant != null)
SizeTransition(
sizeFactor: _controlsAnimation,
axis: Axis.vertical,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: ControlsWidget(
provider.room,
provider.room.localParticipant!,
),
),
),
],
),
onTap: () {
_toggleControls();
},
),
),
),
@@ -150,16 +301,16 @@ class _CallScreenState extends State<CallScreen> {
@override
void deactivate() {
timer?.cancel();
timer = null;
_timer?.cancel();
_timer = null;
super.deactivate();
}
@override
void activate() {
timer ??= Timer.periodic(
_timer ??= Timer.periodic(
const Duration(seconds: 1),
(_) => updateDuration(),
(_) => _updateDuration(),
);
super.activate();
}

View File

@@ -11,7 +11,6 @@ import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/call.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/router.dart';
@@ -19,7 +18,7 @@ import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/chat/call/call_prejoin.dart';
import 'package:solian/widgets/channel/channel_call_indicator.dart';
import 'package:solian/widgets/chat/call/chat_call_action.dart';
import 'package:solian/widgets/chat/chat_event.dart';
import 'package:solian/widgets/chat/chat_event_list.dart';
@@ -53,7 +52,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
late final ChatEventController _chatController;
getChannel({String? alias}) async {
_getChannel({String? alias}) async {
final ChannelProvider provider = Get.find();
setState(() => _isBusy = true);
@@ -80,7 +79,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
setState(() => _isBusy = false);
}
getOngoingCall() async {
_getOngoingCall() async {
final ChannelProvider provider = Get.find();
setState(() => _isBusy = true);
@@ -100,7 +99,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
setState(() => _isBusy = false);
}
void listenMessages() {
void _listenMessages() {
final WebSocketProvider provider = Get.find();
_subscription = provider.stream.stream.listen((event) {
switch (event.method) {
@@ -110,26 +109,20 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
break;
case 'calls.new':
final payload = Call.fromJson(event.payload!);
setState(() => _ongoingCall = payload);
if (payload.channel.id == _channel!.id) {
setState(() => _ongoingCall = payload);
}
break;
case 'calls.end':
setState(() => _ongoingCall = null);
final payload = Call.fromJson(event.payload!);
if (payload.channel.id == _channel!.id) {
setState(() => _ongoingCall = null);
}
break;
}
});
}
void showCallPrejoin() {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ChatCallPrejoinPopup(
ongoingCall: _ongoingCall!,
channel: _channel!,
),
);
}
Event? _messageToReplying;
Event? _messageToEditing;
@@ -149,13 +142,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_chatController = ChatEventController();
_chatController.initialize();
getChannel().then((_) {
_getOngoingCall();
_getChannel().then((_) {
_chatController.getEvents(_channel!, widget.realm);
listenMessages();
_listenMessages();
});
getOngoingCall();
super.initState();
}
@@ -183,8 +175,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
final ChatCallProvider call = Get.find();
return Scaffold(
appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context),
@@ -219,7 +209,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
if (value == false) AppRouter.instance.pop();
if (value != null) {
final resp = Channel.fromJson(value as Map<String, dynamic>);
getChannel(alias: resp.alias);
_getChannel(alias: resp.alias);
}
});
},
@@ -232,32 +222,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
body: Column(
children: [
if (_ongoingCall != null)
MaterialBanner(
padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4),
leading: const Icon(Icons.call_received),
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
dividerColor: Colors.transparent,
content: Text('callOngoing'.tr),
actions: [
Obx(() {
if (call.current.value == null) {
return TextButton(
onPressed: showCallPrejoin,
child: Text('callJoin'.tr),
);
} else if (call.channel.value?.id == _channel?.id) {
return TextButton(
onPressed: () => call.gotoScreen(context),
child: Text('callResume'.tr),
);
} else {
return TextButton(
onPressed: null,
child: Text('callJoin'.tr),
);
}
})
],
ChannelCallIndicator(
channel: _channel!,
ongoingCall: _ongoingCall!,
),
Expanded(
child: ChatEventList(

View File

@@ -4,6 +4,7 @@ import 'package:solian/controllers/post_list_controller.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart';
import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/current_state_action.dart';
@@ -51,7 +52,9 @@ class _HomeScreenState extends State<HomeScreen>
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => const PostCreatePopup(),
builder: (context) => PostCreatePopup(
controller: _postController,
),
);
},
),
@@ -118,12 +121,12 @@ class _HomeScreenState extends State<HomeScreen>
class PostCreatePopup extends StatelessWidget {
final bool hideDraftBox;
final Function? onCreated;
final PostListController controller;
const PostCreatePopup({
super.key,
this.hideDraftBox = false,
this.onCreated,
required this.controller,
});
@override
@@ -140,11 +143,13 @@ class PostCreatePopup extends StatelessWidget {
label: 'postEditorModeStory'.tr,
onTap: () {
Navigator.pop(context);
AppRouter.instance.pushNamed('postEditor', queryParameters: {
'mode': 0.toString(),
}).then((val) {
if (val != null && onCreated != null) onCreated!();
});
AppRouter.instance.pushNamed(
'postEditor',
extra: PostPublishArguments(postListController: controller),
queryParameters: {
'mode': 0.toString(),
},
);
},
),
(
@@ -152,11 +157,13 @@ class PostCreatePopup extends StatelessWidget {
label: 'postEditorModeArticle'.tr,
onTap: () {
Navigator.pop(context);
AppRouter.instance.pushNamed('postEditor', queryParameters: {
'mode': 1.toString(),
}).then((val) {
if (val != null && onCreated != null) onCreated!();
});
AppRouter.instance.pushNamed(
'postEditor',
extra: PostPublishArguments(postListController: controller),
queryParameters: {
'mode': 1.toString(),
},
);
},
),
(

View File

@@ -3,14 +3,18 @@ import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/posts/post_replies.dart';
class PostDetailScreen extends StatefulWidget {
final String id;
final Post? post;
const PostDetailScreen({super.key, required this.id});
const PostDetailScreen({
super.key,
required this.id,
this.post,
});
@override
State<PostDetailScreen> createState() => _PostDetailScreenState();
@@ -20,6 +24,11 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
Post? item;
Future<Post?> getDetail() async {
if (widget.post != null) {
item = widget.post;
return widget.post;
}
final PostProvider provider = Get.find();
try {
@@ -48,14 +57,12 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: CenteredContainer(
child: PostItem(
item: item!,
isClickable: true,
isFullDate: true,
isShowReply: false,
isContentSelectable: true,
),
child: PostItem(
item: item!,
isClickable: true,
isFullDate: true,
isShowReply: false,
isContentSelectable: true,
),
),
SliverToBoxAdapter(
@@ -63,14 +70,12 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
.paddingOnly(top: 4),
),
SliverToBoxAdapter(
child: CenteredContainer(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'postReplies'.tr,
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 16),
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'postReplies'.tr,
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 16),
),
),
PostReplyList(item: item!),

View File

@@ -5,9 +5,11 @@ import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:markdown_toolbar/markdown_toolbar.dart';
import 'package:solian/controllers/post_editor_controller.dart';
import 'package:solian/controllers/post_list_controller.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/post.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/theme.dart';
@@ -21,8 +23,15 @@ class PostPublishArguments {
final Post? reply;
final Post? repost;
final Realm? realm;
final PostListController? postListController;
PostPublishArguments({this.edit, this.reply, this.repost, this.realm});
PostPublishArguments({
this.edit,
this.reply,
this.repost,
this.realm,
this.postListController,
});
}
class PostPublishScreen extends StatefulWidget {
@@ -30,6 +39,7 @@ class PostPublishScreen extends StatefulWidget {
final Post? reply;
final Post? repost;
final Realm? realm;
final PostListController? postListController;
final int mode;
const PostPublishScreen({
@@ -38,6 +48,7 @@ class PostPublishScreen extends StatefulWidget {
this.reply,
this.repost,
this.realm,
this.postListController,
required this.mode,
});
@@ -56,6 +67,14 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
if (auth.isAuthorized.isFalse) return;
if (_editorController.isEmpty) return;
final AttachmentUploaderController uploader = Get.find();
if (uploader.queueOfUpload.any(
((x) => x.usage == 'i.attachment' && x.isUploading),
)) {
context.showErrorDialog('attachmentUploadInProgress'.tr);
return;
}
setState(() => _isBusy = true);
final client = auth.configureClient('interactive');
@@ -76,6 +95,9 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
context.showErrorDialog(resp.bodyString);
} else {
_editorController.localClear();
if (widget.postListController != null) {
widget.postListController!.reloadAllOver();
}
AppRouter.instance.pop(resp.body);
}
@@ -92,20 +114,29 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
}
}
void cancelAction() {
void _cancelAction() {
_editorController.localClear();
AppRouter.instance.pop();
}
Post? get _editTo => _editorController.editTo.value;
Post? get _replyTo => _editorController.replyTo.value;
Post? get _repostTo => _editorController.repostTo.value;
Realm? get _realm => _editorController.realmZone.value;
@override
void initState() {
super.initState();
if (widget.edit == null) _editorController.localRead();
if (widget.edit == null && widget.reply == null && widget.repost == null) {
_editorController.localRead();
}
if (widget.reply != null) {
_editorController.replyTo.value = widget.reply;
}
if (widget.repost != null) {
_editorController.repostTo.value = widget.repost;
}
_editorController.contentController.addListener(() => setState(() {}));
_syncWidget();
}
@@ -114,7 +145,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
Widget build(BuildContext context) {
final notifyBannerActions = [
TextButton(
onPressed: cancelAction,
onPressed: _cancelAction,
child: Text('cancel'.tr),
)
];
@@ -196,10 +227,15 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
collapsedBackgroundColor:
Theme.of(context).colorScheme.surfaceContainer,
children: [
PostItem(
item: _replyTo!,
isReactable: false,
).paddingOnly(bottom: 8),
Container(
constraints: const BoxConstraints(maxHeight: 280),
child: SingleChildScrollView(
child: PostItem(
item: _replyTo!,
isReactable: false,
).paddingOnly(bottom: 8),
),
),
],
),
if (_repostTo != null)
@@ -214,10 +250,15 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
collapsedBackgroundColor:
Theme.of(context).colorScheme.surfaceContainer,
children: [
PostItem(
item: _repostTo!,
isReactable: false,
).paddingOnly(bottom: 8),
Container(
constraints: const BoxConstraints(maxHeight: 280),
child: SingleChildScrollView(
child: PostItem(
item: _repostTo!,
isReactable: false,
).paddingOnly(bottom: 8),
),
),
],
),
Expanded(

View File

@@ -26,7 +26,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
final List<Realm> _realms = List.empty(growable: true);
getRealms() async {
_getRealms() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
@@ -48,7 +48,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
@override
void initState() {
super.initState();
getRealms();
_getRealms();
}
@override
@@ -71,7 +71,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
onPressed: () {
AppRouter.instance.pushNamed('realmOrganizing').then(
(value) {
if (value != null) getRealms();
if (value != null) _getRealms();
},
);
},
@@ -84,7 +84,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
body: Obx(() {
if (auth.isAuthorized.isFalse) {
return SigninRequiredOverlay(
onSignedIn: () => getRealms(),
onSignedIn: () => _getRealms(),
);
}
@@ -94,12 +94,12 @@ class _RealmListScreenState extends State<RealmListScreen> {
Expanded(
child: CenteredContainer(
child: RefreshIndicator(
onRefresh: () => getRealms(),
onRefresh: () => _getRealms(),
child: ListView.builder(
itemCount: _realms.length,
itemBuilder: (context, index) {
final element = _realms[index];
return buildRealm(element);
return _buildEntry(element);
},
),
),
@@ -112,7 +112,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
);
}
Widget buildRealm(Realm element) {
Widget _buildEntry(Realm element) {
return Card(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),

View File

@@ -12,7 +12,6 @@ import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/channel/channel_organize.dart';
import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/channel/channel_list.dart';
@@ -136,6 +135,7 @@ class _RealmViewScreenState extends State<RealmViewScreen> {
}
return TabBarView(
physics: const NeverScrollableScrollPhysics(),
children: [
RealmPostListWidget(realm: _realm!),
RealmChannelListWidget(
@@ -189,7 +189,6 @@ class _RealmPostListWidgetState extends State<RealmPostListWidget> {
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(getPosts);
}
@@ -199,28 +198,6 @@ class _RealmPostListWidgetState extends State<RealmPostListWidget> {
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: ListTile(
leading: const Icon(Icons.post_add),
contentPadding: const EdgeInsets.only(left: 24, right: 8),
tileColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text('postNew'.tr),
subtitle: Text(
'postNewInRealmHint'
.trParams({'realm': '#${widget.realm.alias}'}),
),
onTap: () {
AppRouter.instance
.pushNamed(
'postEditor',
extra: PostPublishArguments(realm: widget.realm),
)
.then((value) {
if (value != null) _pagingController.refresh();
});
},
),
),
PostListWidget(controller: _pagingController),
],
),

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/exts.dart';
import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/router.dart';
import 'package:solian/theme.dart';
class SettingScreen extends StatefulWidget {
@@ -79,6 +80,21 @@ class _SettingScreenState extends State<SettingScreen> {
.toList(),
).paddingSymmetric(horizontal: 12, vertical: 8),
),
_buildCaptionHeader('more'.tr),
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
TextButton(
style: const ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
child: Text('about'.tr),
onPressed: () {
AppRouter.instance.pushNamed('about');
},
),
],
).paddingSymmetric(horizontal: 12, vertical: 8),
],
),
);

View File

@@ -8,13 +8,15 @@ import 'package:solian/widgets/app_bar_leading.dart';
class TitleShell extends StatelessWidget {
final bool showAppBar;
final bool isCenteredTitle;
final GoRouterState state;
final String? title;
final GoRouterState? state;
final Widget child;
const TitleShell({
super.key,
required this.child,
required this.state,
this.title,
this.state,
this.showAppBar = true,
this.isCenteredTitle = false,
});
@@ -25,7 +27,9 @@ class TitleShell extends StatelessWidget {
appBar: showAppBar
? AppBar(
leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle(state.topRoute?.name?.tr ?? 'page'.tr),
title: AppBarTitle(
title ?? (state!.topRoute?.name?.tr ?? 'page'.tr),
),
centerTitle: isCenteredTitle,
toolbarHeight: SolianTheme.toolbarHeight(context),
)

View File

@@ -10,8 +10,10 @@ const i18nEnglish = {
'draft': 'Draft',
'draftSave': 'Save',
'draftBox': 'Draft Box',
'more': 'More',
'share': 'Share',
'feed': 'Feed',
'unlink': 'Unlink',
'feedSearch': 'Search Feed',
'feedSearchWithTag': 'Searching with tag #@key',
'feedSearchWithCategory': 'Searching in category @category',
@@ -50,6 +52,7 @@ const i18nEnglish = {
'account': 'Account',
'accountPersonalize': 'Personalize',
'accountPersonalizeApplied': 'Account personalize settings has been saved.',
'accountStickers': 'Stickers',
'accountFriend': 'Friend',
'accountFriendNew': 'New friend',
'accountFriendNewHint':
@@ -160,8 +163,11 @@ const i18nEnglish = {
'attachmentAutoUpload': 'Auto Upload',
'attachmentUploadQueue': 'Upload Queue',
'attachmentUploadQueueStart': 'Start All',
'attachmentUploadInProgress':
'There are attachments being uploaded. Please wait until all attachments have been uploaded before proceeding...',
'attachmentAttached': 'Exists Files',
'attachmentUploadBlocked': 'Upload blocked, there is currently a task in progress...',
'attachmentUploadBlocked':
'Upload blocked, there is currently a task in progress...',
'attachmentAdd': 'Attach attachments',
'attachmentAddGalleryPhoto': 'Gallery photo',
'attachmentAddGalleryVideo': 'Gallery video',
@@ -169,6 +175,10 @@ const i18nEnglish = {
'attachmentAddCameraVideo': 'Capture video',
'attachmentAddClipboard': 'Paste file',
'attachmentAddFile': 'Attach file',
'attachmentAddLink': 'Link attachments',
'attachmentAddLinkHint':
'Enter attachment serial number to link that attachment',
'attachmentAddLinkInput': 'Serial number',
'attachmentSetting': 'Adjust attachment',
'attachmentAlt': 'Alternative text',
'attachmentLoadFailed': 'Load Attachment Failed',
@@ -251,6 +261,8 @@ const i18nEnglish = {
'Are your sure to delete message @id? This action cannot be undone!',
'call': 'Call',
'callOngoing': 'A call is ongoing...',
'callOngoingEmpty': 'A call is on hold...',
'callOngoingParticipants': '@count people are calling...',
'callJoin': 'Join',
'callResume': 'Resume',
'callMicrophone': 'Microphone',
@@ -306,6 +318,13 @@ const i18nEnglish = {
'accountStatusNeutral': 'Neutral',
'accountStatusPositive': 'Positive',
'bsLoadingTheme': 'Loading Theme',
'bsCheckForUpdate': 'Checking For Updates',
'bsCheckForUpdateFailed': 'Unable to Check Updates',
'bsCheckForUpdateNew': 'Found New Version',
'bsCheckForUpdateDescApple':
'Please head to TestFlight and update your app to latest version to prevent error happens and get latest functions.',
'bsCheckForUpdateDescCommon':
'Please head to our website download and install latest version of application to prevent error happens and get latest functions.',
'bsCheckingServer': 'Checking Server Status',
'bsCheckingServerFail':
'Unable connect to server, check your network connection',
@@ -323,6 +342,22 @@ const i18nEnglish = {
'themeColorMiku': 'Miku Blue',
'themeColorKagamine': 'Kagamine Yellow',
'themeColorLuka': 'Luka Pink',
'stickerDeletionConfirm': 'Confirm sticker delete',
'stickerDeletionConfirmCaption':
'Are you sure to delete sticker @name? This action cannot be undo.',
'themeColorApplied': 'Global theme color has been applied.',
'attachmentSaved': 'Attachment saved to your system album.',
'cropImage': 'Crop Image',
'stickerUploader': 'Upload sticker',
'stickerUploaderAttachmentNew': 'Upload new attachment',
'stickerUploaderAttachment': 'Attachment serial number',
'stickerUploaderPack': 'Sticker pack serial number',
'stickerUploaderPackHint':
'Don\'t have pack id? Head to creator platform and create one!',
'stickerUploaderAlias': 'Alias',
'stickerUploaderAliasHint':
'Will be used as a placeholder with the sticker pack prefix when entered.',
'stickerUploaderName': 'Name',
'stickerUploaderNameHint':
'A human-friendly name given to the user in the sticker selection interface.',
};

View File

@@ -18,8 +18,10 @@ const i18nSimplifiedChinese = {
'draft': '草稿',
'draftSave': '存为草稿',
'draftBox': '草稿箱',
'more': '更多',
'share': '分享',
'feed': '资讯',
'unlink': '移除链接',
'feedSearch': '搜索资讯',
'feedSearchWithTag': '检索带有 #@key 标签的资讯',
'feedSearchWithCategory': '检索位于分类 @category 的资讯',
@@ -50,6 +52,7 @@ const i18nSimplifiedChinese = {
'account': '账号',
'accountPersonalize': '个性化',
'accountPersonalizeApplied': '账户的个性化设置已保存。',
'accountStickers': '贴图',
'accountFriend': '好友',
'accountFriendNew': '添加好友',
'accountFriendNewHint': '使用他人的用户名来发送一个好友请求吧!',
@@ -149,6 +152,7 @@ const i18nSimplifiedChinese = {
'attachmentAutoUpload': '自动上传',
'attachmentUploadQueue': '上传队列',
'attachmentUploadQueueStart': '整队上传',
'attachmentUploadInProgress': '有附件正在上传,请等待所有附件上传完毕后再进行操作……',
'attachmentAttached': '已附附件',
'attachmentUploadBlocked': '上传受阻,当前已有任务进行中……',
'attachmentAdd': '附加附件',
@@ -158,6 +162,9 @@ const i18nSimplifiedChinese = {
'attachmentAddCameraVideo': '拍摄视频',
'attachmentAddClipboard': '粘贴文件',
'attachmentAddFile': '附加文件',
'attachmentAddLink': '链接附件',
'attachmentAddLinkHint': '输入附件的神秘代号来链接对应附件',
'attachmentAddLinkInput': '神秘代号',
'attachmentSetting': '调整附件',
'attachmentAlt': '替代文字',
'attachmentLoadFailed': '加载失败',
@@ -232,6 +239,8 @@ const i18nSimplifiedChinese = {
'messageDeletionConfirmCaption': '你确定要删除消息 @id 吗?该操作不可撤销。',
'call': '通话',
'callOngoing': '一则通话正在进行中…',
'callOngoingEmpty': '一则通话待机中…',
'callOngoingParticipants': '@count 人正在进行通话…',
'callJoin': '加入',
'callResume': '恢复',
'callMicrophone': '麦克风',
@@ -285,6 +294,11 @@ const i18nSimplifiedChinese = {
'accountStatusNeutral': '中性',
'accountStatusPositive': '积极',
'bsLoadingTheme': '正在装载主题',
'bsCheckForUpdate': '正在检查更新',
'bsCheckForUpdateFailed': '无法检查更新',
'bsCheckForUpdateNew': '发现新版本',
'bsCheckForUpdateDescApple': '请前往 TestFlight 并将您的应用程序更新到最新版本,以防止出现错误并获取最新功能。',
'bsCheckForUpdateDescCommon': '请前往我们的网站下载并安装最新版本的应用程序,以防止出现错误并获取最新功能。',
'bsCheckingServer': '检查服务器状态中',
'bsCheckingServerFail': '无法连接至服务器,请检查你的网络连接状态',
'bsCheckingServerDown': '当前服务器不可用,请稍后重试',
@@ -301,5 +315,17 @@ const i18nSimplifiedChinese = {
'themeColorKagamine': '镜音黄',
'themeColorLuka': '流音粉',
'themeColorApplied': '全局主题颜色已应用',
'stickerDeletionConfirm': '确认删除贴图',
'stickerDeletionConfirmCaption': '你确认要删除贴图 @name 吗?该操作不可撤销。',
'attachmentSaved': '附件已保存到系统相册',
'cropImage': '裁剪图片',
'stickerUploader': '上传贴图',
'stickerUploaderAttachmentNew': '上传附件',
'stickerUploaderAttachment': '附件序列号',
'stickerUploaderPack': '贴图包序号',
'stickerUploaderPackHint': '没有该序号?请转到我们的创作者平台创建一个贴图包。',
'stickerUploaderAlias': '贴图别名',
'stickerUploaderAliasHint': '将会在输入时使用和贴图包前缀组成占位符。',
'stickerUploaderName': '贴图名称',
'stickerUploaderNameHint': '在贴图选择界面提供给用户的人类友好名称。',
};

View File

@@ -8,9 +8,9 @@ import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_heading.dart';
class AccountProfilePopup extends StatefulWidget {
final Account account;
final String name;
const AccountProfilePopup({super.key, required this.account});
const AccountProfilePopup({super.key, required this.name});
@override
State<AccountProfilePopup> createState() => _AccountProfilePopupState();
@@ -21,11 +21,11 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
Account? _userinfo;
void getUserinfo() async {
void _getUserinfo() async {
setState(() => _isBusy = true);
final client = ServiceFinder.configureClient('auth');
final resp = await client.get('/users/${widget.account.name}');
final resp = await client.get('/users/${widget.name}');
if (resp.statusCode == 200) {
_userinfo = Account.fromJson(resp.body);
setState(() => _isBusy = false);
@@ -38,7 +38,7 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
@override
void initState() {
super.initState();
getUserinfo();
_getUserinfo();
}
@override

View File

@@ -226,7 +226,7 @@ class _AccountStatusEditorDialogState extends State<AccountStatusEditorDialog> {
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 5),
const SizedBox(height: 8),
TextField(
controller: _clearAtController,
readOnly: true,
@@ -238,7 +238,7 @@ class _AccountStatusEditorDialogState extends State<AccountStatusEditorDialog> {
),
onTap: () => selectClearAt(),
),
const SizedBox(height: 5),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(
@@ -281,7 +281,7 @@ class _AccountStatusEditorDialogState extends State<AccountStatusEditorDialog> {
],
),
),
const SizedBox(height: 5),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap(

View File

@@ -35,7 +35,7 @@ class SilverRelativeList extends StatelessWidget {
context: context,
builder: (context) =>
AccountProfilePopup(
account: element.related,
name: element.related.name,
),
);
},

View File

@@ -8,9 +8,10 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:path/path.dart' show basename;
import 'package:path/path.dart' show basename, extension;
import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/platform.dart';
@@ -22,16 +23,26 @@ import 'package:solian/widgets/attachments/attachment_fullscreen.dart';
class AttachmentEditorPopup extends StatefulWidget {
final String usage;
final List<int> initialAttachments;
final bool singleMode;
final bool imageOnly;
final bool autoUpload;
final double? imageMaxWidth;
final double? imageMaxHeight;
final List<int>? initialAttachments;
final void Function(int) onAdd;
final void Function(int) onRemove;
const AttachmentEditorPopup({
super.key,
required this.usage,
required this.initialAttachments,
required this.onAdd,
required this.onRemove,
this.singleMode = false,
this.imageOnly = false,
this.autoUpload = false,
this.imageMaxWidth,
this.imageMaxHeight,
this.initialAttachments,
});
@override
@@ -42,7 +53,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
final _imagePicker = ImagePicker();
final AttachmentUploaderController _uploadController = Get.find();
bool _isAutoUpload = false;
late bool _isAutoUpload = widget.autoUpload;
bool _isBusy = false;
bool _isFirstTimeBusy = true;
@@ -53,13 +64,26 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
final medias = await _imagePicker.pickMultiImage();
if (medias.isEmpty) return;
if (widget.singleMode) {
final medias = await _imagePicker.pickMultiImage(
maxWidth: widget.imageMaxWidth,
maxHeight: widget.imageMaxHeight,
);
if (medias.isEmpty) return;
_enqueueTaskBatch(medias.map((x) {
final file = File(x.path);
return AttachmentUploadTask(file: file, usage: widget.usage);
}));
} else {
final media = await _imagePicker.pickMedia(
maxWidth: widget.imageMaxWidth,
maxHeight: widget.imageMaxHeight,
);
if (media == null) return;
for (final media in medias) {
final file = File(media.path);
_enqueueTask(
AttachmentUploadTask(file: file, usage: widget.usage),
AttachmentUploadTask(file: File(media.path), usage: widget.usage),
);
}
}
@@ -88,11 +112,9 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
List<File> files = result.paths.map((path) => File(path!)).toList();
for (final file in files) {
_enqueueTask(
AttachmentUploadTask(file: file, usage: widget.usage),
);
}
_enqueueTaskBatch(files.map((x) {
return AttachmentUploadTask(file: x, usage: widget.usage);
}));
}
Future<void> _takeMediaToUpload(bool isVideo) async {
@@ -113,6 +135,64 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
);
}
Future<void> _linkAttachments() async {
final controller = TextEditingController();
final input = await showDialog<String?>(
context: context,
builder: (context) {
return AlertDialog(
title: Text('attachmentAddLink'.tr),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('attachmentAddLinkHint'.tr, textAlign: TextAlign.left),
const SizedBox(height: 18),
TextField(
controller: controller,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'attachmentAddLinkInput'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr),
),
TextButton(
child: Text('next'.tr),
onPressed: () {
Navigator.pop(context, controller.text);
},
),
],
);
},
);
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
if (input == null || input.isEmpty) return;
final value = int.tryParse(input);
if (value == null) return;
final AttachmentProvider attach = Get.find();
final result = await attach.getMetadata(value);
if (result != null) {
widget.onAdd(result.id);
setState(() => _attachments.add(result));
if (widget.singleMode) Navigator.pop(context);
}
}
void _pasteFileToUpload() async {
final data = await Pasteboard.image;
if (data == null) return;
@@ -125,9 +205,11 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
widget.usage,
null,
(item) {
if (item == null) return;
widget.onAdd(item.id);
if (mounted) {
setState(() => _attachments.add(item));
if (widget.singleMode) Navigator.pop(context);
}
},
);
@@ -153,30 +235,30 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
}
void _revertMetadataList() {
final AttachmentProvider provider = Get.find();
final AttachmentProvider attach = Get.find();
if (widget.initialAttachments.isEmpty) {
if (widget.initialAttachments?.isEmpty ?? true) {
_isFirstTimeBusy = false;
return;
} else {
_attachments = List.filled(widget.initialAttachments.length, null);
_attachments = List.filled(
widget.initialAttachments!.length,
null,
growable: true,
);
}
setState(() => _isBusy = true);
int progress = 0;
for (var idx = 0; idx < widget.initialAttachments.length; idx++) {
provider.getMetadata(widget.initialAttachments[idx]).then((resp) {
progress++;
_attachments[idx] = resp;
if (progress == widget.initialAttachments.length) {
setState(() {
_isBusy = false;
_isFirstTimeBusy = false;
});
}
attach
.listMetadata(widget.initialAttachments ?? List.empty())
.then((result) {
setState(() {
_attachments = result;
_isBusy = false;
_isFirstTimeBusy = false;
});
}
});
}
void _showAttachmentPreview(Attachment element) {
@@ -203,6 +285,31 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
);
}
Future<void> _cropAttachment(int queueIndex) async {
final task = _uploadController.queueOfUpload[queueIndex];
CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: task.file.path,
uiSettings: [
AndroidUiSettings(
toolbarTitle: 'cropImage'.tr,
toolbarColor: Theme.of(context).colorScheme.primary,
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
aspectRatioPresets: CropAspectRatioPreset.values,
),
IOSUiSettings(
title: 'cropImage'.tr,
aspectRatioPresets: CropAspectRatioPreset.values,
),
WebUiSettings(
context: context,
),
],
);
if (croppedFile == null) return;
_uploadController.queueOfUpload[queueIndex].file = File(croppedFile.path);
_uploadController.queueOfUpload.refresh();
}
Future<void> _deleteAttachment(Attachment element) async {
setState(() => _isBusy = true);
try {
@@ -216,6 +323,9 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
}
Widget _buildQueueEntry(AttachmentUploadTask element, int index) {
final extName = extension(element.file.path).substring(1);
final canBeCrop = ['png', 'jpg', 'jpeg', 'gif'].contains(extName);
return Container(
padding: const EdgeInsets.only(left: 16, right: 8, bottom: 16),
child: Card(
@@ -239,20 +349,9 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
fontFamily: 'monospace',
),
),
FutureBuilder(
future: element.file.length(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Text(
'- Bytes',
style: TextStyle(fontSize: 12),
);
}
return Text(
_formatBytes(snapshot.data!),
style: const TextStyle(fontSize: 12),
);
},
Text(
'In queue #${index + 1}',
style: const TextStyle(fontSize: 12),
),
],
),
@@ -280,23 +379,52 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
child: Icon(Icons.check),
),
),
if (!element.isCompleted && !element.isUploading)
if (element.error != null)
IconButton(
color: Colors.green,
icon: const Icon(Icons.play_arrow),
visualDensity: const VisualDensity(horizontal: -4),
onPressed: _uploadController.isUploading.value
? null
: () {
_uploadController
.performSingleTask(index)
.then((r) {
widget.onAdd(r.id);
if (mounted) {
setState(() => _attachments.add(r));
}
});
},
tooltip: element.error!.toString(),
icon: const Icon(Icons.warning),
onPressed: () {},
),
if (!element.isCompleted &&
element.error == null &&
canBeCrop)
Obx(
() => IconButton(
color: Colors.teal,
icon: const Icon(Icons.crop),
visualDensity: const VisualDensity(horizontal: -4),
onPressed: _uploadController.isUploading.value
? null
: () {
_cropAttachment(index);
},
),
),
if (!element.isCompleted &&
!element.isUploading &&
element.error == null)
Obx(
() => IconButton(
color: Colors.green,
icon: const Icon(Icons.play_arrow),
visualDensity: const VisualDensity(horizontal: -4),
onPressed: _uploadController.isUploading.value
? null
: () {
_uploadController
.performSingleTask(index)
.then((r) {
if (r == null) return;
widget.onAdd(r.id);
if (mounted) {
setState(() => _attachments.add(r));
if (widget.singleMode) {
Navigator.pop(context);
}
}
});
},
),
),
if (!element.isCompleted && !element.isUploading)
IconButton(
@@ -392,6 +520,19 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
});
},
),
PopupMenuItem(
child: ListTile(
title: Text('unlink'.tr),
leading: const Icon(Icons.link_off),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
),
),
onTap: () {
widget.onRemove(element.id);
setState(() => _attachments.removeAt(index));
},
),
],
),
],
@@ -410,11 +551,19 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
}
}
void _enqueueTaskBatch(Iterable<AttachmentUploadTask> tasks) {
_uploadController.enqueueTaskBatch(tasks);
if (_isAutoUpload) {
_startUploading();
}
}
void _startUploading() {
_uploadController.performUploadQueue(onData: (r) {
widget.onAdd(r.id);
if (mounted) {
setState(() => _attachments.add(r));
if (widget.singleMode) Navigator.pop(context);
}
});
}
@@ -433,11 +582,10 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
child: DropTarget(
onDragDone: (detail) async {
if (_uploadController.isUploading.value) return;
for (final file in detail.files) {
_enqueueTask(
AttachmentUploadTask(file: File(file.path), usage: widget.usage),
);
}
_enqueueTaskBatch(detail.files.map((x) {
final file = File(x.path);
return AttachmentUploadTask(file: file, usage: widget.usage);
}));
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -448,18 +596,23 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'attachmentAdd'.tr,
style: Theme.of(context).textTheme.headlineSmall,
Expanded(
child: Text(
'attachmentAdd'.tr,
style: Theme.of(context).textTheme.headlineSmall,
maxLines: 2,
overflow: TextOverflow.fade,
),
),
const SizedBox(width: 10),
Obx(() {
if (_uploadController.isUploading.value) {
return const SizedBox(
return SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2.5,
value: _uploadController.progressOfUpload.value,
),
);
}
@@ -566,6 +719,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
ignoring: _uploadController.isUploading.value,
child: Container(
height: 64,
width: MediaQuery.of(context).size.width,
decoration: BoxDecoration(
border: Border(
top: BorderSide(
@@ -582,9 +736,10 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
children: [
if (PlatformInfo.isDesktop ||
PlatformInfo.isIOS ||
PlatformInfo.isWeb)
if ((PlatformInfo.isDesktop ||
PlatformInfo.isIOS ||
PlatformInfo.isWeb) &&
!widget.imageOnly)
ElevatedButton.icon(
icon: const Icon(Icons.paste),
label: Text('attachmentAddClipboard'.tr),
@@ -597,30 +752,40 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
style: const ButtonStyle(visualDensity: density),
onPressed: () => _pickPhotoToUpload(),
),
ElevatedButton.icon(
icon: const Icon(Icons.add_road),
label: Text('attachmentAddGalleryVideo'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _pickVideoToUpload(),
),
if (!widget.imageOnly)
ElevatedButton.icon(
icon: const Icon(Icons.add_road),
label: Text('attachmentAddGalleryVideo'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _pickVideoToUpload(),
),
ElevatedButton.icon(
icon: const Icon(Icons.photo_camera_back),
label: Text('attachmentAddCameraPhoto'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _takeMediaToUpload(false),
),
ElevatedButton.icon(
icon: const Icon(Icons.video_camera_back_outlined),
label: Text('attachmentAddCameraVideo'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _takeMediaToUpload(true),
),
ElevatedButton.icon(
icon: const Icon(Icons.file_present_rounded),
label: Text('attachmentAddFile'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _pickFileToUpload(),
),
if (!widget.imageOnly)
ElevatedButton.icon(
icon: const Icon(Icons.video_camera_back_outlined),
label: Text('attachmentAddCameraVideo'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _takeMediaToUpload(true),
),
if (!widget.imageOnly)
ElevatedButton.icon(
icon: const Icon(Icons.file_present_rounded),
label: Text('attachmentAddFile'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _pickFileToUpload(),
),
if (!widget.imageOnly)
ElevatedButton.icon(
icon: const Icon(Icons.link),
label: Text('attachmentAddFile'.tr),
style: const ButtonStyle(visualDensity: density),
onPressed: () => _linkAttachments(),
),
],
).paddingSymmetric(horizontal: 12),
),

View File

@@ -257,6 +257,10 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
child: Wrap(
spacing: 6,
children: [
Text(
'#${widget.item.id}',
style: metaTextStyle,
),
if (widget.item.metadata?['width'] != null &&
widget.item.metadata?['height'] != null)
Text(

View File

@@ -45,7 +45,7 @@ class _AttachmentListState extends State<AttachmentList> {
List<Attachment?> _attachmentsMeta = List.empty();
void _getMetadataList() {
final AttachmentProvider provider = Get.find();
final AttachmentProvider attach = Get.find();
if (widget.attachmentsId.isEmpty) {
return;
@@ -53,25 +53,16 @@ class _AttachmentListState extends State<AttachmentList> {
_attachmentsMeta = List.filled(widget.attachmentsId.length, null);
}
int progress = 0;
for (var idx = 0; idx < widget.attachmentsId.length; idx++) {
provider.getMetadata(widget.attachmentsId[idx]).then((resp) {
progress++;
if (resp != null) {
_attachmentsMeta[idx] = resp;
}
if (progress == widget.attachmentsId.length) {
calculateAspectRatio();
if (mounted) {
setState(() => _isLoading = false);
}
}
attach.listMetadata(widget.attachmentsId).then((result) {
setState(() {
_attachmentsMeta = result;
_isLoading = false;
});
}
_calculateAspectRatio();
});
}
void calculateAspectRatio() {
void _calculateAspectRatio() {
bool isConsistent = true;
double? consistentValue;
int portrait = 0, square = 0, landscape = 0;

View File

@@ -0,0 +1,89 @@
import 'dart:convert';
import 'package:avatar_stack/avatar_stack.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/call.dart';
import 'package:solian/widgets/chat/call/call_prejoin.dart';
class ChannelCallIndicator extends StatelessWidget {
final Channel channel;
final Call ongoingCall;
const ChannelCallIndicator(
{super.key, required this.channel, required this.ongoingCall});
void _showCallPrejoin(BuildContext context) {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ChatCallPrejoinPopup(
ongoingCall: ongoingCall,
channel: channel,
),
);
}
@override
Widget build(BuildContext context) {
final ChatCallProvider call = Get.find();
return MaterialBanner(
padding: const EdgeInsets.only(left: 16, top: 4, bottom: 4),
leading: const Icon(Icons.call_received),
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
dividerColor: Colors.transparent,
content: Row(
children: [
if (ongoingCall.participants.isEmpty) Text('callOngoingEmpty'.tr),
if (ongoingCall.participants.isNotEmpty)
Text('callOngoingParticipants'.trParams({
'count': ongoingCall.participants.length.toString(),
})),
const SizedBox(width: 6),
if (ongoingCall.participants.isNotEmpty)
Container(
height: 28,
constraints: const BoxConstraints(maxWidth: 120),
child: AvatarStack(
height: 28,
borderWidth: 0,
avatars: ongoingCall.participants.map((x) {
final userinfo = Account.fromJson(jsonDecode(x['metadata']));
return PlatformInfo.canCacheImage
? CachedNetworkImageProvider(userinfo.avatar)
as ImageProvider
: NetworkImage(userinfo.avatar) as ImageProvider;
}).toList(),
),
),
],
),
actions: [
Obx(() {
if (call.current.value == null) {
return TextButton(
onPressed: () => _showCallPrejoin(context),
child: Text('callJoin'.tr),
);
} else if (call.channel.value?.id == channel.id) {
return TextButton(
onPressed: () => call.gotoScreen(context),
child: Text('callResume'.tr),
);
} else {
return TextButton(
onPressed: null,
child: Text('callJoin'.tr),
);
}
})
],
);
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/platform.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/account/account_avatar.dart';
@@ -31,7 +33,9 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
final List<Channel> _globalChannels = List.empty(growable: true);
final Map<String, List<Channel>> _inRealms = {};
void mapChannels() {
final ChatEventController _eventController = ChatEventController();
void _mapChannels() {
_inRealms.clear();
_globalChannels.clear();
@@ -55,16 +59,17 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
@override
void didUpdateWidget(covariant ChannelListWidget oldWidget) {
super.didUpdateWidget(oldWidget);
setState(() => mapChannels());
setState(() => _mapChannels());
}
@override
void initState() {
super.initState();
mapChannels();
_mapChannels();
_eventController.initialize();
}
void gotoChannel(Channel item) {
void _gotoChannel(Channel item) {
if (widget.useReplace) {
AppRouter.instance.pushReplacementNamed(
'channelChat',
@@ -88,7 +93,35 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
}
}
Widget buildItem(Channel item) {
Widget _buildDirectMessageDescription(Channel item, ChannelMember otherside) {
if (PlatformInfo.isWeb) {
return Text('channelDirectDescription'.trParams(
{'username': '@${otherside.account.name}'},
));
}
return FutureBuilder(
future: Future.delayed(
const Duration(milliseconds: 500),
() => _eventController.database.localEvents.findLastByChannel(item.id),
),
builder: (context, snapshot) {
if (!snapshot.hasData && snapshot.data == null) {
return Text('channelDirectDescription'.trParams(
{'username': '@${otherside.account.name}'},
));
}
return Text(
'${snapshot.data!.data.sender.account.nick}: ${snapshot.data!.data.body['text'] ?? 'Unsupported message to preview'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
);
}
Widget _buildEntry(Channel item) {
final padding = widget.isDense
? const EdgeInsets.symmetric(horizontal: 20)
: const EdgeInsets.symmetric(horizontal: 16);
@@ -102,37 +135,36 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
leading: AccountAvatar(
content: otherside.account.avatar,
radius: widget.isDense ? 12 : 20,
bgColor: Colors.indigo,
feColor: Colors.white,
bgColor: Theme.of(context).colorScheme.primary,
feColor: Theme.of(context).colorScheme.onPrimary,
),
contentPadding: padding,
title: Text(otherside.account.nick),
subtitle: !widget.isDense
? Text(
'channelDirectDescription'.trParams(
{'username': '@${otherside.account.name}'},
),
)
? _buildDirectMessageDescription(item, otherside)
: null,
onTap: () => gotoChannel(item),
onTap: () => _gotoChannel(item),
);
} else {
return ListTile(
minTileHeight: widget.isDense ? 48 : null,
leading: CircleAvatar(
backgroundColor:
item.realmId == null ? Colors.indigo : Colors.transparent,
backgroundColor: item.realmId == null
? Theme.of(context).colorScheme.primary
: Colors.transparent,
radius: widget.isDense ? 12 : 20,
child: FaIcon(
FontAwesomeIcons.hashtag,
color: item.realmId == null ? Colors.white : Colors.indigo,
color: item.realmId == null
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.primary,
size: widget.isDense ? 12 : 16,
),
),
contentPadding: padding,
title: Text(item.name),
subtitle: !widget.isDense ? Text(item.description) : null,
onTap: () => gotoChannel(item),
onTap: () => _gotoChannel(item),
);
}
}
@@ -146,7 +178,7 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
itemCount: _globalChannels.length,
itemBuilder: (context, index) {
final element = _globalChannels[index];
return buildItem(element);
return _buildEntry(element);
},
),
],
@@ -159,13 +191,13 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
itemCount: _globalChannels.length,
itemBuilder: (context, index) {
final element = _globalChannels[index];
return buildItem(element);
return _buildEntry(element);
},
),
SliverList.list(
children: _inRealms.entries.map((element) {
return ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 20),
tilePadding: const EdgeInsets.only(left: 20, right: 24),
minTileHeight: 48,
title: Text(element.value.first.realm!.name),
leading: CircleAvatar(
@@ -177,7 +209,7 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
size: widget.isDense ? 12 : 16,
),
),
children: element.value.map((x) => buildItem(x)).toList(),
children: element.value.map((x) => _buildEntry(x)).toList(),
);
}).toList(),
),

View File

@@ -57,7 +57,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
setState(() => _isBusy = false);
}
void promptAddMember() async {
void _promptAddMember() async {
final input = await showModalBottomSheet(
context: context,
builder: (context) {
@@ -141,7 +141,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
'channelMembersAddHint'
.trParams({'channel': '#${widget.channel.alias}'}),
),
onTap: () => promptAddMember(),
onTap: () => _promptAddMember(),
),
Expanded(
child: ListView.builder(
@@ -160,7 +160,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
account: element.account,
name: element.account.name,
),
);
},

View File

@@ -23,7 +23,7 @@ class ControlsWidget extends StatefulWidget {
}
class _ControlsWidgetState extends State<ControlsWidget> {
CameraPosition position = CameraPosition.front;
CameraPosition _position = CameraPosition.front;
List<MediaDevice>? _audioInputs;
List<MediaDevice>? _audioOutputs;
@@ -36,25 +36,25 @@ class _ControlsWidgetState extends State<ControlsWidget> {
@override
void initState() {
super.initState();
participant.addListener(onChange);
_participant.addListener(onChange);
_subscription = Hardware.instance.onDeviceChange.stream
.listen((List<MediaDevice> devices) {
revertDevices(devices);
_revertDevices(devices);
});
Hardware.instance.enumerateDevices().then(revertDevices);
Hardware.instance.enumerateDevices().then(_revertDevices);
_speakerphoneOn = Hardware.instance.speakerOn ?? false;
}
@override
void dispose() {
_subscription?.cancel();
participant.removeListener(onChange);
_participant.removeListener(onChange);
super.dispose();
}
LocalParticipant get participant => widget.participant;
LocalParticipant get _participant => widget.participant;
void revertDevices(List<MediaDevice> devices) async {
void _revertDevices(List<MediaDevice> devices) async {
_audioInputs = devices.where((d) => d.kind == 'audioinput').toList();
_audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList();
_videoInputs = devices.where((d) => d.kind == 'videoinput').toList();
@@ -63,7 +63,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
void onChange() => setState(() {});
bool get isMuted => participant.isMuted;
bool get isMuted => _participant.isMuted;
Future<bool?> showDisconnectDialog() {
return showDialog<bool>(
@@ -85,7 +85,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
);
}
void disconnect() async {
void _disconnect() async {
if (await showDisconnectDialog() != true) return;
final ChatCallProvider provider = Get.find();
@@ -95,59 +95,59 @@ class _ControlsWidgetState extends State<ControlsWidget> {
}
}
void disableAudio() async {
await participant.setMicrophoneEnabled(false);
void _disableAudio() async {
await _participant.setMicrophoneEnabled(false);
}
void enableAudio() async {
await participant.setMicrophoneEnabled(true);
void _enableAudio() async {
await _participant.setMicrophoneEnabled(true);
}
void disableVideo() async {
await participant.setCameraEnabled(false);
void _disableVideo() async {
await _participant.setCameraEnabled(false);
}
void enableVideo() async {
await participant.setCameraEnabled(true);
void _enableVideo() async {
await _participant.setCameraEnabled(true);
}
void selectAudioOutput(MediaDevice device) async {
void _selectAudioOutput(MediaDevice device) async {
await widget.room.setAudioOutputDevice(device);
setState(() {});
}
void selectAudioInput(MediaDevice device) async {
void _selectAudioInput(MediaDevice device) async {
await widget.room.setAudioInputDevice(device);
setState(() {});
}
void selectVideoInput(MediaDevice device) async {
void _selectVideoInput(MediaDevice device) async {
await widget.room.setVideoInputDevice(device);
setState(() {});
}
void setSpeakerphoneOn() {
void _setSpeakerphoneOn() {
_speakerphoneOn = !_speakerphoneOn;
Hardware.instance.setSpeakerphoneOn(_speakerphoneOn);
setState(() {});
}
void toggleCamera() async {
final track = participant.videoTrackPublications.firstOrNull?.track;
void _toggleCamera() async {
final track = _participant.videoTrackPublications.firstOrNull?.track;
if (track == null) return;
try {
final newPosition = position.switched();
final newPosition = _position.switched();
await track.setCameraPosition(newPosition);
setState(() {
position = newPosition;
_position = newPosition;
});
} catch (error) {
return;
}
}
void enableScreenShare() async {
void _enableScreenShare() async {
if (lkPlatformIsDesktop()) {
try {
final source = await showDialog<DesktopCapturerSource>(
@@ -163,7 +163,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
maxFrameRate: 15.0,
),
);
await participant.publishVideoTrack(track);
await _participant.publishVideoTrack(track);
} catch (e) {
final message = e.toString();
context.showErrorDialog(message);
@@ -177,7 +177,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
maxFrameRate: 30.0,
),
);
await participant.publishVideoTrack(track);
await _participant.publishVideoTrack(track);
return;
}
@@ -188,11 +188,11 @@ class _ControlsWidgetState extends State<ControlsWidget> {
return;
}
await participant.setScreenShareEnabled(true, captureScreenAudio: true);
await _participant.setScreenShareEnabled(true, captureScreenAudio: true);
}
void disableScreenShare() async {
await participant.setScreenShareEnabled(false);
void _disableScreenShare() async {
await _participant.setScreenShareEnabled(false);
}
@override
@@ -210,12 +210,12 @@ class _ControlsWidgetState extends State<ControlsWidget> {
icon: Transform.flip(
flipX: true, child: const Icon(Icons.exit_to_app)),
color: Theme.of(context).colorScheme.onSurface,
onPressed: disconnect,
onPressed: _disconnect,
),
if (participant.isMicrophoneEnabled())
if (_participant.isMicrophoneEnabled())
if (lkPlatformIs(PlatformType.android))
IconButton(
onPressed: disableAudio,
onPressed: _disableAudio,
icon: const Icon(Icons.mic),
color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callMicrophoneOff'.tr,
@@ -227,7 +227,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
return [
PopupMenuItem<MediaDevice>(
value: null,
onTap: isMuted ? enableAudio : disableAudio,
onTap: isMuted ? _enableAudio : _disableAudio,
child: ListTile(
leading: const Icon(Icons.mic_off),
title: Text(isMuted
@@ -246,7 +246,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => selectAudioInput(device),
onTap: () => _selectAudioInput(device),
);
})
];
@@ -254,19 +254,19 @@ class _ControlsWidgetState extends State<ControlsWidget> {
)
else
IconButton(
onPressed: enableAudio,
onPressed: _enableAudio,
icon: const Icon(Icons.mic_off),
color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callMicrophoneOn'.tr,
),
if (participant.isCameraEnabled())
if (_participant.isCameraEnabled())
PopupMenuButton<MediaDevice>(
icon: const Icon(Icons.videocam_sharp),
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<MediaDevice>(
value: null,
onTap: disableVideo,
onTap: _disableVideo,
child: ListTile(
leading: const Icon(Icons.videocam_off),
title: Text('callCameraOff'.tr),
@@ -283,7 +283,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => selectVideoInput(device),
onTap: () => _selectVideoInput(device),
);
})
];
@@ -291,17 +291,17 @@ class _ControlsWidgetState extends State<ControlsWidget> {
)
else
IconButton(
onPressed: enableVideo,
onPressed: _enableVideo,
icon: const Icon(Icons.videocam_off),
color: Theme.of(context).colorScheme.onSurface,
tooltip: 'callCameraOn'.tr,
),
IconButton(
icon: Icon(position == CameraPosition.back
icon: Icon(_position == CameraPosition.back
? Icons.video_camera_back
: Icons.video_camera_front),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => toggleCamera(),
onPressed: () => _toggleCamera(),
tooltip: 'callVideoFlip'.tr,
),
if (!lkPlatformIs(PlatformType.iOS))
@@ -327,7 +327,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
: const Icon(Icons.check_box_outline_blank),
title: Text(device.label),
),
onTap: () => selectAudioOutput(device),
onTap: () => _selectAudioOutput(device),
);
})
];
@@ -336,7 +336,7 @@ class _ControlsWidgetState extends State<ControlsWidget> {
if (!kIsWeb && lkPlatformIs(PlatformType.iOS))
IconButton(
onPressed: Hardware.instance.canSwitchSpeakerphone
? setSpeakerphoneOn
? _setSpeakerphoneOn
: null,
color: Theme.of(context).colorScheme.onSurface,
icon: Icon(
@@ -344,18 +344,18 @@ class _ControlsWidgetState extends State<ControlsWidget> {
),
tooltip: 'callSpeakerphoneToggle'.tr,
),
if (participant.isScreenShareEnabled())
if (_participant.isScreenShareEnabled())
IconButton(
icon: const Icon(Icons.monitor_outlined),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => disableScreenShare(),
onPressed: () => _disableScreenShare(),
tooltip: 'callScreenOff'.tr,
)
else
IconButton(
icon: const Icon(Icons.monitor),
color: Theme.of(context).colorScheme.onSurface,
onPressed: () => enableScreenShare(),
onPressed: () => _enableScreenShare(),
tooltip: 'callScreenOn'.tr,
),
],

View File

@@ -201,7 +201,7 @@ class InteractiveParticipantWidget extends StatelessWidget {
final double? width;
final double? height;
final Color? color;
final bool isFixed;
final bool isFixedAvatar;
final ParticipantTrack participant;
final Function() onTap;
@@ -210,35 +210,32 @@ class InteractiveParticipantWidget extends StatelessWidget {
this.width,
this.height,
this.color,
this.isFixed = false,
this.isFixedAvatar = false,
required this.participant,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
child: Container(
width: width,
height: height,
color: color,
child: ParticipantWidget.widgetFor(participant, isFixed: isFixed),
),
onTap: () => onTap(),
onLongPress: () {
if (participant.participant is LocalParticipant) return;
showModalBottomSheet(
context: context,
builder: (context) => ParticipantMenu(
participant: participant.participant as RemoteParticipant,
videoTrack: participant.videoTrack,
isScreenShare: participant.isScreenShare,
),
);
},
return GestureDetector(
child: Container(
width: width,
height: height,
color: color,
child: ParticipantWidget.widgetFor(participant, isFixed: isFixedAvatar),
),
onTap: () => onTap(),
onLongPress: () {
if (participant.participant is LocalParticipant) return;
showModalBottomSheet(
context: context,
builder: (context) => ParticipantMenu(
participant: participant.participant as RemoteParticipant,
videoTrack: participant.videoTrack,
isScreenShare: participant.isScreenShare,
),
);
},
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:livekit_client/livekit_client.dart';
class ParticipantInfoWidget extends StatelessWidget {
@@ -34,38 +35,42 @@ class ParticipantInfoWidget extends StatelessWidget {
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(width: 5),
isScreenShare
? const Padding(
padding: EdgeInsets.only(left: 5),
child: Icon(
Icons.monitor,
color: Colors.white,
size: 16,
),
? const Icon(
Icons.monitor,
color: Colors.white,
size: 16,
)
: Padding(
padding: const EdgeInsets.only(left: 5),
child: Icon(
audioAvailable ? Icons.mic : Icons.mic_off,
color: audioAvailable ? Colors.white : Colors.red,
size: 16,
),
: Icon(
audioAvailable ? Icons.mic : Icons.mic_off,
color: audioAvailable ? Colors.white : Colors.red,
size: 16,
),
const SizedBox(width: 3),
if (connectionQuality != ConnectionQuality.unknown)
Padding(
padding: const EdgeInsets.only(left: 5),
child: Icon(
connectionQuality == ConnectionQuality.poor
? Icons.wifi_off_outlined
: Icons.wifi,
color: {
ConnectionQuality.excellent: Colors.green,
ConnectionQuality.good: Colors.orange,
ConnectionQuality.poor: Colors.red,
}[connectionQuality],
size: 16,
Icon(
{
ConnectionQuality.excellent: Icons.signal_cellular_alt,
ConnectionQuality.good: Icons.signal_cellular_alt_2_bar,
ConnectionQuality.poor: Icons.signal_cellular_alt_1_bar,
}[connectionQuality],
color: {
ConnectionQuality.excellent: Colors.green,
ConnectionQuality.good: Colors.orange,
ConnectionQuality.poor: Colors.red,
}[connectionQuality],
size: 16,
)
else
const SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
).paddingAll(3),
],
),
);

View File

@@ -9,19 +9,21 @@ class ChatCallCurrentIndicator extends StatelessWidget {
Widget build(BuildContext context) {
final ChatCallProvider provider = Get.find();
if (provider.current.value == null || provider.channel.value == null) {
return const SizedBox();
}
return Obx(() {
if (provider.current.value == null || provider.channel.value == null) {
return const SizedBox();
}
return ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 32),
leading: const Icon(Icons.call),
title: Text(provider.channel.value!.name),
subtitle: Text('callAlreadyOngoing'.tr),
onTap: () {
provider.gotoScreen(context);
},
);
return ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 32),
leading: const Icon(Icons.call),
title: Text(provider.channel.value!.name),
subtitle: Text('callAlreadyOngoing'.tr),
onTap: () {
provider.gotoScreen(context);
},
);
});
}
}

View File

@@ -243,7 +243,7 @@ class ChatEvent extends StatelessWidget {
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
account: item.sender.account,
name: item.sender.account.name,
),
);
},

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:solian/controllers/chat_events_controller.dart';
import 'package:solian/models/channel.dart';
@@ -23,7 +24,7 @@ class ChatEventList extends StatelessWidget {
required this.onReply,
});
bool checkMessageMergeable(Event? a, Event? b) {
bool _checkMessageMergeable(Event? a, Event? b) {
if (a == null || b == null) return false;
if (a.sender.account.id != b.sender.account.id) return false;
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
@@ -41,13 +42,13 @@ class ChatEventList extends StatelessWidget {
itemBuilder: (context, index) {
bool isMerged = false, hasMerged = false;
if (index > 0) {
hasMerged = checkMessageMergeable(
hasMerged = _checkMessageMergeable(
chatController.currentEvents[index - 1].data,
chatController.currentEvents[index].data,
);
}
if (index + 1 < chatController.currentEvents.length) {
isMerged = checkMessageMergeable(
isMerged = _checkMessageMergeable(
chatController.currentEvents[index].data,
chatController.currentEvents[index + 1].data,
);
@@ -82,7 +83,12 @@ class ChatEventList extends StatelessWidget {
),
);
},
);
).animate(key: Key('m-animation${item.uuid}')).slideY(
duration: 250.ms,
curve: Curves.fastEaseInToSlowEaseOut,
end: 0,
begin: 0.5,
);
},
);
}),

View File

@@ -5,9 +5,11 @@ import 'package:solian/exts.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart';
import 'package:solian/providers/attachment_uploader.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/attachments/attachment_editor.dart';
import 'package:solian/widgets/chat/chat_event.dart';
import 'package:badges/badges.dart' as badges;
import 'package:uuid/uuid.dart';
class ChatMessageInput extends StatefulWidget {
@@ -80,6 +82,14 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
final prof = auth.userProfile.value!;
if (auth.isAuthorized.isFalse) return;
final AttachmentUploaderController uploader = Get.find();
if (uploader.queueOfUpload.any(
((x) => x.usage == 'm.attachment' && x.isUploading),
)) {
context.showErrorDialog('attachmentUploadInProgress'.tr);
return;
}
Response resp;
final mentionedUserNames = _findMentionedUsers(_textController.text);
@@ -259,7 +269,18 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
),
),
IconButton(
icon: const Icon(Icons.attach_file),
icon: badges.Badge(
badgeContent: Text(
_attachments.length.toString(),
style: const TextStyle(color: Colors.white),
),
showBadge: _attachments.isNotEmpty,
position: badges.BadgePosition.topEnd(
top: -12,
end: -8,
),
child: const Icon(Icons.file_present_rounded),
),
color: Colors.teal,
onPressed: () => _editAttachments(),
),

View File

@@ -1,8 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown_selectionarea/flutter_markdown.dart';
import 'package:get/get.dart';
import 'package:markdown/markdown.dart' as markdown;
import 'package:markdown/markdown.dart';
import 'package:solian/providers/stickers.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'account/account_profile_popup.dart';
class MarkdownTextContent extends StatelessWidget {
final String content;
final bool isSelectable;
@@ -34,6 +39,8 @@ class MarkdownTextContent extends StatelessWidget {
extensionSet: markdown.ExtensionSet(
markdown.ExtensionSet.gitHubFlavored.blockSyntaxes,
<markdown.InlineSyntax>[
_UserNameCardInlineSyntax(),
_CustomEmoteInlineSyntax(),
markdown.EmojiSyntax(),
markdown.AutolinkExtensionSyntax(),
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes
@@ -41,6 +48,23 @@ class MarkdownTextContent extends StatelessWidget {
),
onTapLink: (text, href, title) async {
if (href == null) return;
if (href.startsWith('solink://')) {
final segments = href.replaceFirst('solink://', '').split('/');
switch (segments[0]) {
case 'users':
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
name: segments[1],
),
);
}
return;
}
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
@@ -57,3 +81,39 @@ class MarkdownTextContent extends StatelessWidget {
return _buildContent(context);
}
}
class _UserNameCardInlineSyntax extends InlineSyntax {
_UserNameCardInlineSyntax() : super(r'@[a-zA-Z0-9_]+');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final alias = match[0]!;
final anchor = markdown.Element.text('a', alias)
..attributes['href'] = Uri.encodeFull(
'solink://users/${alias.substring(1)}',
);
parser.addNode(anchor);
return true;
}
}
class _CustomEmoteInlineSyntax extends InlineSyntax {
_CustomEmoteInlineSyntax() : super(r':([a-z0-9_+-]+):');
@override
bool onMatch(markdown.InlineParser parser, Match match) {
final StickerProvider sticker = Get.find();
final alias = match[1]!;
if (sticker.aliasImageMapping[alias] == null) {
parser.advanceBy(1);
return false;
}
final element = markdown.Element.empty('img');
element.attributes['src'] = sticker.aliasImageMapping[alias]!;
parser.addNode(element);
return true;
}
}

View File

@@ -4,6 +4,7 @@ import 'package:solian/models/account_status.dart';
import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/router.dart';
import 'package:solian/shells/root_shell.dart';
import 'package:solian/theme.dart';
@@ -60,8 +61,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
AppRouter.instance.pushNamed('settings');
setState(() => _selectedIndex = null);
_closeDrawer();
}
);
});
}
@override
@@ -130,22 +130,36 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
);
},
),
leading: Builder(builder: (context) {
final badgeColor = _accountStatus != null
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(
showBadge: _accountStatus != null,
badgeStyle: badges.BadgeStyle(badgeColor: badgeColor),
position: badges.BadgePosition.bottomEnd(
bottom: 0,
end: -2,
badgeContent: Text(
accountNotifications.toString(),
style: const TextStyle(color: Colors.white),
),
child: AccountAvatar(
content: auth.userProfile.value!['avatar'],
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'],
),
),
);
}),

View File

@@ -1,9 +1,11 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get_utils/get_utils.dart';
import 'package:intl/intl.dart';
import 'package:solian/models/post.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/posts/post_detail.dart';
import 'package:solian/shells/title_shell.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/attachments/attachment_list.dart';
@@ -22,7 +24,8 @@ class PostItem extends StatefulWidget {
final bool isShowEmbed;
final bool isFullDate;
final bool isContentSelectable;
final String? overrideAttachmentParent;
final String? attachmentParent;
final Color? backgroundColor;
const PostItem({
super.key,
@@ -34,7 +37,8 @@ class PostItem extends StatefulWidget {
this.isShowEmbed = true,
this.isFullDate = false,
this.isContentSelectable = false,
this.overrideAttachmentParent,
this.attachmentParent,
this.backgroundColor,
});
@override
@@ -159,66 +163,92 @@ class _PostItemState extends State<PostItem> {
}
Widget _buildReply(BuildContext context) {
return Column(
children: [
Row(
children: [
FaIcon(
FontAwesomeIcons.reply,
size: 16,
color: _unFocusColor,
),
Expanded(
child: Text(
'postRepliedNotify'.trParams(
{'username': '@${widget.item.replyTo!.author.name}'},
),
style: TextStyle(color: _unFocusColor),
).paddingOnly(left: 6),
),
],
).paddingOnly(left: 12),
Card(
elevation: 1,
child: PostItem(
item: widget.item.replyTo!,
isCompact: true,
overrideAttachmentParent: widget.item.id.toString(),
).paddingSymmetric(vertical: 8),
return OpenContainer(
closedBuilder: (_, openContainer) => Column(
children: [
Row(
children: [
FaIcon(
FontAwesomeIcons.reply,
size: 16,
color: _unFocusColor,
),
Expanded(
child: Text(
'postRepliedNotify'.trParams(
{'username': '@${widget.item.replyTo!.author.name}'},
),
style: TextStyle(color: _unFocusColor),
).paddingOnly(left: 6),
),
],
).paddingOnly(left: 12),
Card(
elevation: 1,
child: PostItem(
item: widget.item.replyTo!,
isCompact: true,
attachmentParent: widget.item.id.toString(),
).paddingSymmetric(vertical: 8),
),
],
),
openBuilder: (_, __) => TitleShell(
title: 'postDetail'.tr,
child: PostDetailScreen(
id: widget.item.replyTo!.id.toString(),
post: widget.item.replyTo!,
),
],
),
closedElevation: 0,
openElevation: 0,
closedColor: widget.backgroundColor ?? Theme.of(context).colorScheme.surface,
openColor: Theme.of(context).colorScheme.surface,
);
}
Widget _buildRepost(BuildContext context) {
return Column(
children: [
Row(
children: [
FaIcon(
FontAwesomeIcons.retweet,
size: 16,
color: _unFocusColor,
),
Expanded(
child: Text(
'postRepostedNotify'.trParams(
{'username': '@${widget.item.repostTo!.author.name}'},
),
style: TextStyle(color: _unFocusColor),
).paddingOnly(left: 6),
),
],
).paddingOnly(left: 12),
Card(
elevation: 1,
child: PostItem(
item: widget.item.repostTo!,
isCompact: true,
overrideAttachmentParent: widget.item.id.toString(),
).paddingSymmetric(vertical: 8),
return OpenContainer(
closedBuilder: (_, openContainer) => Column(
children: [
Row(
children: [
FaIcon(
FontAwesomeIcons.retweet,
size: 16,
color: _unFocusColor,
),
Expanded(
child: Text(
'postRepostedNotify'.trParams(
{'username': '@${widget.item.repostTo!.author.name}'},
),
style: TextStyle(color: _unFocusColor),
).paddingOnly(left: 6),
),
],
).paddingOnly(left: 12),
Card(
elevation: 1,
child: PostItem(
item: widget.item.repostTo!,
isCompact: true,
attachmentParent: widget.item.id.toString(),
).paddingSymmetric(vertical: 8),
),
],
),
openBuilder: (_, __) => TitleShell(
title: 'postDetail'.tr,
child: PostDetailScreen(
id: widget.item.repostTo!.id.toString(),
post: widget.item.repostTo!,
),
],
),
closedElevation: 0,
openElevation: 0,
closedColor: widget.backgroundColor ?? Theme.of(context).colorScheme.surface,
openColor: Theme.of(context).colorScheme.surface,
);
}
@@ -264,100 +294,91 @@ class _PostItemState extends State<PostItem> {
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: AccountAvatar(content: item.author.avatar.toString()),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
account: item.author,
),
);
},
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedContainer(
maxWidth: 640,
child: MarkdownTextContent(
content: item.body['content'],
isSelectable: widget.isContentSelectable,
).paddingOnly(left: 12, right: 8),
),
if (widget.item.replyTo != null && widget.isShowEmbed)
GestureDetector(
child: _buildReply(context).paddingOnly(top: 4),
onTap: () {
if (!widget.isClickable) return;
AppRouter.instance.pushNamed(
'postDetail',
pathParameters: {
'id': widget.item.replyTo!.id.toString(),
},
);
},
return OpenContainer(
closedBuilder: (_, openContainer) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: AccountAvatar(content: item.author.avatar.toString()),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
name: item.author.name,
),
if (widget.item.repostTo != null && widget.isShowEmbed)
GestureDetector(
child: _buildRepost(context).paddingOnly(top: 4),
onTap: () {
if (!widget.isClickable) return;
AppRouter.instance.pushNamed(
'postDetail',
pathParameters: {
'alias': widget.item.repostTo!.id.toString(),
},
);
},
),
_buildFooter().paddingOnly(left: 12),
],
);
},
),
),
],
).paddingOnly(
top: 10,
bottom: hasAttachment ? 10 : 0,
right: 16,
left: 16,
),
AttachmentList(
parentId: widget.item.id.toString(),
attachmentsId: attachments,
isGrid: attachments.length > 1,
),
if (widget.isShowReply && widget.isReactable)
PostQuickAction(
isShowReply: widget.isShowReply,
isReactable: widget.isReactable,
item: widget.item,
onReact: (symbol, changes) {
setState(() {
item.metric!.reactionList[symbol] =
(item.metric!.reactionList[symbol] ?? 0) + changes;
});
},
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(),
SizedContainer(
maxWidth: 640,
child: MarkdownTextContent(
content: item.body['content'],
isSelectable: widget.isContentSelectable,
).paddingOnly(left: 12, right: 8),
),
if (widget.item.replyTo != null && widget.isShowEmbed)
_buildReply(context).paddingOnly(top: 4),
if (widget.item.repostTo != null && widget.isShowEmbed)
_buildRepost(context).paddingOnly(top: 4),
_buildFooter().paddingOnly(left: 12),
],
),
),
],
).paddingOnly(
top: hasAttachment ? 10 : 6,
left: hasAttachment ? 24 : 60,
top: 10,
bottom: hasAttachment ? 10 : 0,
right: 16,
bottom: 10,
)
else
const SizedBox(height: 10),
],
left: 16,
),
AttachmentList(
parentId: widget.item.id.toString(),
attachmentsId: attachments,
isGrid: attachments.length > 1,
),
if (widget.isShowReply && widget.isReactable)
PostQuickAction(
isShowReply: widget.isShowReply,
isReactable: widget.isReactable,
item: widget.item,
onReact: (symbol, changes) {
setState(() {
item.metric!.reactionList[symbol] =
(item.metric!.reactionList[symbol] ?? 0) + changes;
});
},
).paddingOnly(
top: hasAttachment ? 10 : 6,
left: hasAttachment ? 24 : 60,
right: 16,
bottom: 10,
)
else
const SizedBox(height: 10),
],
),
openBuilder: (_, __) => TitleShell(
title: 'postDetail'.tr,
child: PostDetailScreen(
id: item.id.toString(),
post: item,
),
),
closedElevation: 0,
openElevation: 0,
closedColor: widget.backgroundColor ?? Theme.of(context).colorScheme.surface,
openColor: Theme.of(context).colorScheme.surface,
);
}
}

View File

@@ -3,8 +3,6 @@ import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/post_item.dart';
@@ -13,6 +11,7 @@ class PostListWidget extends StatelessWidget {
final bool isClickable;
final bool isNestedClickable;
final PagingController<int, Post> controller;
final Color? backgroundColor;
const PostListWidget({
super.key,
@@ -20,6 +19,7 @@ class PostListWidget extends StatelessWidget {
this.isShowEmbed = true,
this.isClickable = true,
this.isNestedClickable = true,
this.backgroundColor,
});
@override
@@ -29,16 +29,15 @@ class PostListWidget extends StatelessWidget {
pagingController: controller,
builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) {
return CenteredContainer(
child: PostListEntryWidget(
isShowEmbed: isShowEmbed,
isNestedClickable: isNestedClickable,
isClickable: isClickable,
item: item,
onUpdate: () {
controller.refresh();
},
),
return PostListEntryWidget(
isShowEmbed: isShowEmbed,
isNestedClickable: isNestedClickable,
isClickable: isClickable,
item: item,
backgroundColor: backgroundColor,
onUpdate: () {
controller.refresh();
},
);
},
),
@@ -54,6 +53,7 @@ class PostListEntryWidget extends StatelessWidget {
final bool isClickable;
final Post item;
final Function onUpdate;
final Color? backgroundColor;
const PostListEntryWidget({
super.key,
@@ -63,6 +63,7 @@ class PostListEntryWidget extends StatelessWidget {
required this.isClickable,
required this.item,
required this.onUpdate,
this.backgroundColor,
});
@override
@@ -73,14 +74,8 @@ class PostListEntryWidget extends StatelessWidget {
item: item,
isShowEmbed: isShowEmbed,
isClickable: isNestedClickable,
backgroundColor: backgroundColor,
).paddingSymmetric(vertical: 8),
onTap: () {
if (!isClickable) return;
AppRouter.instance.pushNamed(
'postDetail',
pathParameters: {'id': item.id.toString()},
);
},
onLongPress: () {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;

View File

@@ -8,10 +8,12 @@ import 'package:solian/widgets/posts/post_list.dart';
class PostReplyList extends StatefulWidget {
final Post item;
final Color? backgroundColor;
const PostReplyList({
super.key,
required this.item,
this.backgroundColor,
});
@override
@@ -45,7 +47,6 @@ class _PostReplyListState extends State<PostReplyList> {
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(getReplies);
}
@@ -54,6 +55,7 @@ class _PostReplyListState extends State<PostReplyList> {
return PostListWidget(
isShowEmbed: false,
controller: _pagingController,
backgroundColor: widget.backgroundColor,
);
}
}
@@ -74,7 +76,12 @@ class PostReplyListPopup extends StatelessWidget {
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
Expanded(
child: CustomScrollView(
slivers: [PostReplyList(item: item)],
slivers: [
PostReplyList(
item: item,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
),
],
),
),
],

View File

@@ -24,6 +24,7 @@ class PostSingleDisplay extends StatelessWidget {
isShowEmbed: true,
isNestedClickable: true,
onUpdate: onUpdate,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
),
),
),

View File

@@ -157,7 +157,7 @@ class _RealmMemberListPopupState extends State<RealmMemberListPopup> {
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
account: element.account,
name: element.account.name,
),
);
},

View File

@@ -0,0 +1,211 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/stickers.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/attachments/attachment_editor.dart';
class StickerUploadDialog extends StatefulWidget {
final Sticker? edit;
const StickerUploadDialog({super.key, this.edit});
@override
State<StickerUploadDialog> createState() => _StickerUploadDialogState();
}
class _StickerUploadDialogState extends State<StickerUploadDialog> {
final TextEditingController _attachmentController = TextEditingController();
final TextEditingController _packController = TextEditingController();
final TextEditingController _aliasController = TextEditingController();
final TextEditingController _nameController = TextEditingController();
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
bool _isBusy = false;
void _promptUploadNewAttachment() {
showModalBottomSheet(
context: context,
builder: (context) => AttachmentEditorPopup(
usage: 'sticker',
singleMode: true,
imageOnly: true,
autoUpload: true,
imageMaxHeight: 28,
imageMaxWidth: 28,
onAdd: (value) {
setState(() {
_attachmentController.text = value.toString();
});
},
initialAttachments: const [],
onRemove: (_) {},
),
);
}
Future<void> _applySticker() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
if ([
_nameController.text.isEmpty,
_aliasController.text.isEmpty,
_packController.text.isEmpty,
_attachmentController.text.isEmpty,
].any((x) => x)) {
return;
}
setState(() => _isBusy = true);
Response resp;
final client = auth.configureClient('files');
if (widget.edit == null) {
resp = await client.post('/stickers', {
'name': _nameController.text,
'alias': _aliasController.text,
'pack_id': int.tryParse(_packController.text),
'attachment_id': int.tryParse(_attachmentController.text),
});
} else {
resp = await client.put('/stickers/${widget.edit!.id}', {
'name': _nameController.text,
'alias': _aliasController.text,
'pack_id': int.tryParse(_packController.text),
'attachment_id': int.tryParse(_attachmentController.text),
});
}
setState(() => _isBusy = false);
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString);
} else {
Navigator.pop(context, true);
}
}
@override
void initState() {
super.initState();
if (widget.edit != null) {
_attachmentController.text = widget.edit!.attachmentId.toString();
_packController.text = widget.edit!.packId.toString();
_aliasController.text = widget.edit!.alias;
_nameController.text = widget.edit!.name;
}
}
@override
void dispose() {
_attachmentController.dispose();
_packController.dispose();
_aliasController.dispose();
_nameController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('stickerUploader'.tr),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text('stickerUploaderAttachmentNew'.tr),
contentPadding: const EdgeInsets.only(left: 16, right: 13),
trailing: const Icon(Icons.chevron_right),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
onTap: () {
_promptUploadNewAttachment();
},
),
const SizedBox(height: 8),
TextField(
controller: _attachmentController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
prefixText: '#',
labelText: 'stickerUploaderAttachment'.tr,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 8),
TextField(
controller: _packController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
prefixText: '#',
labelText: 'stickerUploaderPack'.tr,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
Container(
padding:
const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 6),
child: Text(
'stickerUploaderPackHint'.tr,
style: TextStyle(color: _unFocusColor),
),
),
TextField(
controller: _aliasController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'stickerUploaderAlias'.tr,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
Container(
padding:
const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 6),
child: Text(
'stickerUploaderAliasHint'.tr,
style: TextStyle(color: _unFocusColor),
),
),
TextField(
controller: _nameController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'stickerUploaderName'.tr,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
Container(
padding: const EdgeInsets.only(left: 8, right: 8, top: 4),
child: Text(
'stickerUploaderNameHint'.tr,
style: TextStyle(color: _unFocusColor),
),
),
],
),
actions: [
TextButton(
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
onPressed: _isBusy ? null : () => Navigator.pop(context),
child: Text('cancel'.tr),
),
TextButton(
onPressed: _isBusy ? null : () => _applySticker(),
child: Text('apply'.tr),
),
],
);
}
}

View File

@@ -79,7 +79,7 @@ PODS:
- GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- livekit_client (2.2.2):
- livekit_client (2.2.3):
- FlutterMacOS
- WebRTC-SDK (= 125.6422.04)
- macos_window_utils (1.0.0):
@@ -108,7 +108,7 @@ PODS:
- screen_brightness_macos (0.1.0):
- FlutterMacOS
- Sentry/HybridSDK (8.32.0)
- sentry_flutter (8.5.0):
- sentry_flutter (8.6.0):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.32.0)
@@ -240,7 +240,7 @@ SPEC CHECKSUMS:
gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
livekit_client: c24af2b8474a39325596e714118e05551ec5eacc
livekit_client: a59d8778582019242d96fe9da69d4ec48833b5ca
macos_window_utils: 933f91f64805e2eb91a5bd057cf97cd097276663
media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5
@@ -253,7 +253,7 @@ SPEC CHECKSUMS:
protocol_handler_macos: d10a6c01d6373389ffd2278013ab4c47ed6d6daa
screen_brightness_macos: 2d6d3af2165592d9a55ffcd95b7550970e41ebda
Sentry: 96ae1dcdf01a644bc3a3b1dc279cecaf48a833fb
sentry_flutter: f1d86adcb93a959bc47a40d8d55059bdf7569bc5
sentry_flutter: 090351ce1ff5f96a4b33ef9455b7e3b28185387d
share_plus: 36537c04ce0c3e3f5bd297ce4318b6d5ee5fd6cf
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -25,6 +25,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.4.1"
animations:
dependency: "direct main"
description:
name: animations
sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb
url: "https://pub.dev"
source: hosted
version: "2.0.11"
archive:
dependency: transitive
description:
@@ -42,13 +50,21 @@ packages:
source: hosted
version: "2.5.0"
async:
dependency: transitive
dependency: "direct main"
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
avatar_stack:
dependency: "direct main"
description:
name: avatar_stack
sha256: e4a1576f7478add964bbb8aa5e530db39288fbbf81c30c4fb4b81162dd68aa49
url: "https://pub.dev"
source: hosted
version: "1.2.0"
badges:
dependency: "direct main"
description:
@@ -133,26 +149,26 @@ packages:
dependency: "direct main"
description:
name: cached_network_image
sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f"
sha256: "4a5d8d2c728b0f3d0245f69f921d7be90cae4c2fd5288f773088672c0893f819"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "3.4.0"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f"
sha256: ff0c949e323d2a1b52be73acce5b4a7b04063e61414c8ca542dbba47281630a7
url: "https://pub.dev"
source: hosted
version: "4.0.0"
version: "4.1.0"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7"
sha256: "6322dde7a5ad92202e64df659241104a43db20ed594c41ca18de1014598d7996"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.3.0"
carousel_slider:
dependency: "direct main"
description:
@@ -245,10 +261,10 @@ packages:
dependency: transitive
description:
name: cross_file
sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32"
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
url: "https://pub.dev"
source: hosted
version: "0.3.4+1"
version: "0.3.4+2"
crypto:
dependency: "direct main"
description:
@@ -301,10 +317,10 @@ packages:
dependency: transitive
description:
name: dev_build
sha256: "5600100e28f7424ed53728e8e7aa6bc0e0506ec04bb49a82616f62112c1822c3"
sha256: f526d1fbe68875f6119ffc333f114dfe6aa93ad04439276d53968f7977cc410e
url: "https://pub.dev"
source: hosted
version: "1.0.0+8"
version: "1.0.0+11"
device_info_plus:
dependency: "direct main"
description:
@@ -353,14 +369,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.9"
easy_debounce:
dependency: "direct main"
description:
name: easy_debounce
sha256: f082609cfb8f37defb9e37fc28bc978c6712dedf08d4c5a26f820fa10165a236
url: "https://pub.dev"
source: hosted
version: "2.0.3"
fake_async:
dependency: transitive
description:
@@ -668,10 +676,10 @@ packages:
dependency: "direct main"
description:
name: flutter_webrtc
sha256: d305793e6737c59a81c45b18484e1f985710827704eeb9092573387efcbae272
sha256: f46bd76cef6e8d787dc707d0c591f0e89c912a2970c7b5e68a55b9cca1bdde4c
url: "https://pub.dev"
source: hosted
version: "0.11.5"
version: "0.11.6"
font_awesome_flutter:
dependency: "direct main"
description:
@@ -716,10 +724,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: "39dd52168d6c59984454183148dc3a5776960c61083adfc708cc79a7b3ce1ba8"
sha256: d380de0355788c5c784fe9f81b43fc833b903991c25ecc4e2a416a67faefa722
url: "https://pub.dev"
source: hosted
version: "14.2.1"
version: "14.2.2"
graphs:
dependency: transitive
description:
@@ -753,7 +761,7 @@ packages:
source: hosted
version: "4.0.2"
image:
dependency: "direct main"
dependency: transitive
description:
name: image
sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8"
@@ -804,10 +812,10 @@ packages:
dependency: transitive
description:
name: image_picker_for_web
sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3"
sha256: "65d94623e15372c5c51bebbcb820848d7bcb323836e12dfdba60b5d3a8b39e50"
url: "https://pub.dev"
source: hosted
version: "3.0.4"
version: "3.0.5"
image_picker_ios:
dependency: transitive
description:
@@ -932,10 +940,10 @@ packages:
dependency: "direct main"
description:
name: livekit_client
sha256: e6b1e8a3cdcae95f7e62c0371590648444bac245fce3a1bcfb4ec05889ad82f3
sha256: be2a3375851a6147d5de94a870edd6e831ab8d3d793e3563ba1ff1b05490b3de
url: "https://pub.dev"
source: hosted
version: "2.2.2"
version: "2.2.3"
logging:
dependency: transitive
description:
@@ -1092,10 +1100,10 @@ packages:
dependency: transitive
description:
name: octo_image
sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d"
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
version: "2.1.0"
package_config:
dependency: transitive
description:
@@ -1436,18 +1444,18 @@ packages:
dependency: transitive
description:
name: sentry
sha256: "60756499f09c3ed944640d7993ac527a89f7c3033f13ec12ae145706b269b5c8"
sha256: "76ad4fab90ff82427c26939bd79dc4df345a081e2b1cd5954b947e340b9af9a5"
url: "https://pub.dev"
source: hosted
version: "8.5.0"
version: "8.6.0"
sentry_flutter:
dependency: "direct main"
description:
name: sentry_flutter
sha256: "26cfe89cb08a60d9bc0c4e748a0508e223ae378265aec8ed2a2b48f0d2c936b9"
sha256: a7c92014701093a7c0a373e1a47c54ec428d8468d8bf2b793fee7ea1b085c21b
url: "https://pub.dev"
source: hosted
version: "8.5.0"
version: "8.6.0"
share_plus:
dependency: "direct main"
description:
@@ -1468,10 +1476,10 @@ packages:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3f888ba2d659f3e75f4686112cc1e71f46177f74452d40d8307edc332296ead
sha256: c272f9cabca5a81adc9b0894381e9c1def363e980f960fa903c604c471b22f68
url: "https://pub.dev"
source: hosted
version: "2.3.0"
version: "2.3.1"
shared_preferences_android:
dependency: transitive
description:
@@ -1508,10 +1516,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_web
sha256: "3a293170d4d9403c3254ee05b84e62e8a9b3c5808ebd17de6a33fe9ea6457936"
sha256: "59dc807b94d29d52ddbb1b3c0d3b9d0a67fc535a64e62a5542c8db0513fcb6c2"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
version: "2.4.1"
shared_preferences_windows:
dependency: transitive
description:
@@ -1685,14 +1693,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.0"
textfield_tags:
dependency: "direct main"
description:
name: textfield_tags
sha256: d1f2204114157a1296bb97c20d7f8c8c7fd036212812afb2e19de7bb34acc55b
url: "https://pub.dev"
source: hosted
version: "3.0.1"
timeago:
dependency: "direct main"
description:
@@ -1793,10 +1793,10 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a"
sha256: a36e2d7981122fa185006b216eb6b5b97ede3f9a54b7a511bc966971ab98d049
url: "https://pub.dev"
source: hosted
version: "2.3.1"
version: "2.3.2"
url_launcher_windows:
dependency: transitive
description:

View File

@@ -2,7 +2,7 @@ name: solian
description: "The Solar Network App"
publish_to: "none"
version: 1.2.0+6
version: 1.2.1+2
environment:
sdk: ">=3.3.4 <4.0.0"
@@ -27,7 +27,6 @@ dependencies:
crypto: ^3.0.3
path: ^1.9.0
intl: ^0.19.0
image: ^4.1.7
font_awesome_flutter: ^10.7.0
web_socket_channel: ^3.0.0
permission_handler: ^11.3.1
@@ -50,7 +49,6 @@ dependencies:
media_kit: ^1.1.10+1
media_kit_video: ^1.2.4
media_kit_libs_video: ^1.0.4
textfield_tags: ^3.0.1
pasteboard: ^0.2.0
desktop_drop: ^0.4.4
badges: ^3.1.2
@@ -60,12 +58,14 @@ dependencies:
flutter_cache_manager: ^3.3.3
flutter_markdown_selectionarea: ^0.6.17+1
shared_preferences: ^2.2.3
easy_debounce: ^2.0.3
provider: ^6.1.2
gal: ^2.3.0
dio: ^5.5.0+1
image_cropper: ^8.0.1
markdown_toolbar: ^0.5.0
animations: ^2.0.11
avatar_stack: ^1.2.0
async: ^2.11.0
dev_dependencies:
flutter_test:
@@ -113,7 +113,7 @@ flutter:
flutter_launcher_icons:
android:
generate: "launcher_icon"
image_path: "assets/icon-fit.png"
image_path: "assets/icon.png"
ios: true
image_path: "assets/icon.png"
min_sdk_android: 21

Binary file not shown.

Before

Width:  |  Height:  |  Size: 582 B

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Some files were not shown because too many files have changed in this diff Show More