Compare commits
17 Commits
b0f3b6b5c3
...
3.2.0+133
Author | SHA1 | Date | |
---|---|---|---|
457d1bac60
|
|||
02ec11845b
|
|||
612f1bf004
|
|||
fd80b713ad
|
|||
508805368c
|
|||
98eb28a4ec
|
|||
d1a2f59dd1
|
|||
bb9adb963a
|
|||
83e40cd860
|
|||
c06fb12f6a
|
|||
6600cf4df8
|
|||
4293daaa2f
|
|||
866674ddde
|
|||
27d478ba4f
|
|||
cccade763f
|
|||
f760b85186
|
|||
e68c5f4f92
|
@@ -336,6 +336,18 @@
|
||||
"levelingProgress": "Leveling Progress",
|
||||
"levelingProgressExperience": "{} EXP",
|
||||
"levelingProgressLevel": "Level {}",
|
||||
"levelingStage1": "Novice",
|
||||
"levelingStage2": "Apprentice",
|
||||
"levelingStage3": "Journeyman",
|
||||
"levelingStage4": "Adept",
|
||||
"levelingStage5": "Expert",
|
||||
"levelingStage6": "Master",
|
||||
"levelingStage7": "Grandmaster",
|
||||
"levelingStage8": "Legend",
|
||||
"levelingStage9": "Myth",
|
||||
"levelingStage10": "Immortal",
|
||||
"levelingStage11": "Divine",
|
||||
"levelingStage12": "Transcendent",
|
||||
"fileUploadingProgress": "Uploading file #{}: {}%",
|
||||
"removeChatMember": "Remove Chat Room Member",
|
||||
"removeChatMemberHint": "Are you sure to remove this member from the room?",
|
||||
@@ -896,6 +908,15 @@
|
||||
"attachmentOnDevice": "On-device",
|
||||
"attachmentOnCloud": "On-cloud",
|
||||
"attachments": "Attachments",
|
||||
"uploadAttachment": "Upload Attachment",
|
||||
"attachmentPreview": "Attachment Preview",
|
||||
"selectPool": "Select Pool",
|
||||
"choosePool": "Choose a pool",
|
||||
"errorLoadingPools": "Error loading pools",
|
||||
"quotaCostInfo": "This upload will cost {} quota points",
|
||||
"uploadConstraints": "Upload Constraints",
|
||||
"fileSizeExceeded": "File size exceeds the maximum limit of {}",
|
||||
"fileTypeNotAccepted": "File type is not accepted by this pool",
|
||||
"publisherCollabInvitation": "Collabration invitations",
|
||||
"publisherCollabInvitationCount": {
|
||||
"zero": "No invitation",
|
||||
@@ -1012,6 +1033,11 @@
|
||||
"expandPoll": "Expand Poll",
|
||||
"collapsePoll": "Collapse Poll",
|
||||
"embedView": "Embed View",
|
||||
"auto": "Auto",
|
||||
"manual": "Manual",
|
||||
"iframeCode": "Iframe Code",
|
||||
"iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">",
|
||||
"parseIframe": "Parse Iframe",
|
||||
"embedUri": "Embed URI",
|
||||
"aspectRatio": "Aspect Ratio",
|
||||
"renderer": "Renderer",
|
||||
@@ -1023,5 +1049,18 @@
|
||||
"noEmbed": "No embed yet",
|
||||
"save": "Save",
|
||||
"webView": "Web View",
|
||||
"messageActions": "Message Actions"
|
||||
"messageActions": "Message Actions",
|
||||
"viewEmbedLoadHint": "Tap to load",
|
||||
"files": "Files",
|
||||
"confirmDeleteFile": "Are you sure you want to delete this file?",
|
||||
"deleteFile": "Delete File",
|
||||
"failedToDeleteFile": "Failed to delete file",
|
||||
"drive": "Drive",
|
||||
"allPools": "All Pools",
|
||||
"includeRecycled": "Include Recycled",
|
||||
"confirmDeleteRecycledFiles": "Are you sure you want to delete all recycled files?",
|
||||
"deleteRecycledFiles": "Delete Recycled Files",
|
||||
"recycledFilesDeleted": "Recycled files deleted successfully",
|
||||
"failedToDeleteRecycledFiles": "Failed to delete recycled files",
|
||||
"upload": "Upload"
|
||||
}
|
||||
|
@@ -283,6 +283,18 @@
|
||||
"levelingProgress": "等级进度",
|
||||
"levelingProgressExperience": "{} 经验值",
|
||||
"levelingProgressLevel": "等级 {}",
|
||||
"levelingStage1": "新手",
|
||||
"levelingStage2": "学徒",
|
||||
"levelingStage3": "熟练工",
|
||||
"levelingStage4": "行家",
|
||||
"levelingStage5": "专家",
|
||||
"levelingStage6": "大师",
|
||||
"levelingStage7": "宗师",
|
||||
"levelingStage8": "传奇",
|
||||
"levelingStage9": "神话",
|
||||
"levelingStage10": "不朽",
|
||||
"levelingStage11": "神圣",
|
||||
"levelingStage12": "超凡",
|
||||
"fileUploadingProgress": "正在上传文件 #{}: {}%",
|
||||
"removeChatMember": "移除聊天室成员",
|
||||
"removeChatMemberHint": "确定要将此成员从聊天室中移除吗?",
|
||||
|
BIN
assets/images/stickers/angry.png
Normal file
BIN
assets/images/stickers/angry.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/stickers/clap.png
Normal file
BIN
assets/images/stickers/clap.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/images/stickers/confuse.png
Normal file
BIN
assets/images/stickers/confuse.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 668 KiB |
BIN
assets/images/stickers/party.png
Normal file
BIN
assets/images/stickers/party.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
BIN
assets/images/stickers/pray.png
Normal file
BIN
assets/images/stickers/pray.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 666 KiB |
BIN
assets/images/stickers/thumb_up.png
Normal file
BIN
assets/images/stickers/thumb_up.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 623 KiB |
@@ -148,7 +148,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
..where((m) => m.roomId.equals(roomId));
|
||||
|
||||
if (query.isNotEmpty) {
|
||||
final searchTerm = '%${query}%';
|
||||
final searchTerm = '%$query%';
|
||||
selectStatement =
|
||||
selectStatement..where(
|
||||
(m) =>
|
||||
|
@@ -395,7 +395,12 @@ final rpcServerStateProvider =
|
||||
final label = data['args']['activity']['details'] ?? 'Unknown';
|
||||
final appId = socket.clientId;
|
||||
try {
|
||||
await setRemoteActivityStatus(ref, label, appId);
|
||||
await setRemoteActivityStatus(
|
||||
ref,
|
||||
label,
|
||||
appId,
|
||||
data['args']['activity'],
|
||||
);
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'Failed to set remote activity status: $e',
|
||||
@@ -435,6 +440,7 @@ Future<void> setRemoteActivityStatus(
|
||||
Ref ref,
|
||||
String label,
|
||||
String appId,
|
||||
Map<String, dynamic> meta,
|
||||
) async {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
await apiClient.post(
|
||||
@@ -445,6 +451,7 @@ Future<void> setRemoteActivityStatus(
|
||||
'is_automated': true,
|
||||
'label': label,
|
||||
'app_identifier': appId,
|
||||
'meta': meta,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@@ -16,7 +16,7 @@ import "package:island/widgets/alert.dart";
|
||||
import "package:riverpod_annotation/riverpod_annotation.dart";
|
||||
import "package:uuid/uuid.dart";
|
||||
import "package:island/screens/chat/chat.dart";
|
||||
import "package:island/pods/room_providers.dart";
|
||||
import "package:island/pods/chat_rooms.dart";
|
||||
|
||||
part 'messages_notifier.g.dart';
|
||||
|
||||
|
@@ -6,7 +6,6 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/screens/about.dart';
|
||||
import 'package:island/screens/account/credits.dart';
|
||||
import 'package:island/screens/developers/app_detail.dart';
|
||||
import 'package:island/screens/developers/bot_detail.dart';
|
||||
import 'package:island/screens/developers/edit_app.dart';
|
||||
@@ -19,6 +18,7 @@ import 'package:island/screens/developers/edit_project.dart';
|
||||
import 'package:island/screens/developers/new_project.dart';
|
||||
import 'package:island/screens/developers/project_detail.dart';
|
||||
import 'package:island/screens/discovery/articles.dart';
|
||||
import 'package:island/screens/files/file_list.dart';
|
||||
import 'package:island/screens/posts/post_categories_list.dart';
|
||||
import 'package:island/screens/posts/post_category_detail.dart';
|
||||
import 'package:island/screens/posts/post_search.dart';
|
||||
@@ -656,9 +656,9 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (context, state) => const WalletScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'socialCredits',
|
||||
path: '/account/credits',
|
||||
builder: (context, state) => const SocialCreditsScreen(),
|
||||
name: 'files',
|
||||
path: '/account/files',
|
||||
builder: (context, state) => const FileListScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'relationships',
|
||||
|
@@ -141,20 +141,22 @@ class AccountScreen extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
).padding(horizontal: 8),
|
||||
GestureDetector(
|
||||
child: LevelingProgressCard(
|
||||
level: user.value!.profile.level,
|
||||
experience: user.value!.profile.experience,
|
||||
progress: user.value!.profile.levelingProgress,
|
||||
),
|
||||
LevelingProgressCard(
|
||||
isCompact: true,
|
||||
level: user.value!.profile.level,
|
||||
experience: user.value!.profile.experience,
|
||||
progress: user.value!.profile.levelingProgress,
|
||||
onTap: () {
|
||||
context.pushNamed('leveling');
|
||||
},
|
||||
).padding(horizontal: 12),
|
||||
const SizedBox.shrink(),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
@@ -181,6 +183,7 @@ class AccountScreen extends HookConsumerWidget {
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
@@ -206,7 +209,73 @@ class AccountScreen extends HookConsumerWidget {
|
||||
).height(140),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 8),
|
||||
).padding(horizontal: 12),
|
||||
const SizedBox.shrink(),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Symbols.settings, size: 28).padding(bottom: 8),
|
||||
Text('appSettings').tr().fontSize(16).bold(),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
onTap: () {
|
||||
context.pushNamed('settings');
|
||||
},
|
||||
),
|
||||
).height(120),
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.person_edit,
|
||||
size: 28,
|
||||
).padding(bottom: 8),
|
||||
Text('updateYourProfile').tr().fontSize(16).bold(),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
onTap: () {
|
||||
context.pushNamed('profileUpdate');
|
||||
},
|
||||
),
|
||||
).height(120),
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.manage_accounts,
|
||||
size: 28,
|
||||
).padding(bottom: 8),
|
||||
Text('accountSettings').tr().fontSize(16).bold(),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
onTap: () {
|
||||
context.pushNamed('accountSettings');
|
||||
},
|
||||
),
|
||||
).height(120),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.notifications),
|
||||
@@ -235,6 +304,16 @@ class AccountScreen extends HookConsumerWidget {
|
||||
context.pushNamed('wallet');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.files),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('files').tr(),
|
||||
onTap: () {
|
||||
context.pushNamed('files');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.people),
|
||||
@@ -265,16 +344,6 @@ class AccountScreen extends HookConsumerWidget {
|
||||
context.pushNamed('webFeedMarketplace');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.star),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('credits').tr(),
|
||||
onTap: () {
|
||||
context.pushNamed('socialCredits');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
title: Text('abuseReport').tr(),
|
||||
@@ -284,37 +353,6 @@ class AccountScreen extends HookConsumerWidget {
|
||||
onTap: () => context.pushNamed('reportList'),
|
||||
),
|
||||
const Divider(height: 1).padding(vertical: 8),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.settings),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('appSettings').tr(),
|
||||
onTap: () {
|
||||
context.pushNamed('settings');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.person_edit),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('updateYourProfile').tr(),
|
||||
onTap: () {
|
||||
context.pushNamed('profileUpdate');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.manage_accounts),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('accountSettings').tr(),
|
||||
onTap: () {
|
||||
context.pushNamed('accountSettings');
|
||||
},
|
||||
),
|
||||
const Divider(height: 1).padding(vertical: 8),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.info),
|
||||
@@ -333,6 +371,8 @@ class AccountScreen extends HookConsumerWidget {
|
||||
title: Text('debugOptions').tr(),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => DebugSheet(),
|
||||
);
|
||||
|
@@ -4,7 +4,6 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||
@@ -59,94 +58,93 @@ class SocialCreditHistoryNotifier extends _$SocialCreditHistoryNotifier
|
||||
}
|
||||
}
|
||||
|
||||
class SocialCreditsScreen extends HookConsumerWidget {
|
||||
const SocialCreditsScreen({super.key});
|
||||
class SocialCreditsTab extends HookConsumerWidget {
|
||||
const SocialCreditsTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final socialCredits = ref.watch(socialCreditsProvider);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: Text('socialCredits').tr()),
|
||||
body: Column(
|
||||
children: [
|
||||
Card(
|
||||
margin: EdgeInsets.only(left: 16, right: 16, top: 8),
|
||||
child: socialCredits
|
||||
.when(
|
||||
data:
|
||||
(credits) => Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
credits < 100
|
||||
? 'socialCreditsLevelPoor'.tr()
|
||||
: credits < 150
|
||||
? 'socialCreditsLevelNormal'.tr()
|
||||
: credits < 200
|
||||
? 'socialCreditsLevelGood'.tr()
|
||||
: 'socialCreditsLevelExcellent'.tr(),
|
||||
).tr().bold().fontSize(20),
|
||||
Text(
|
||||
'${credits.toStringAsFixed(2)} pts',
|
||||
).fontSize(14),
|
||||
const Gap(8),
|
||||
LinearProgressIndicator(value: credits / 200),
|
||||
],
|
||||
return Column(
|
||||
children: [
|
||||
const Gap(8),
|
||||
Card(
|
||||
margin: const EdgeInsets.only(left: 16, right: 16, top: 8),
|
||||
child: socialCredits
|
||||
.when(
|
||||
data:
|
||||
(credits) => Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
credits < 100
|
||||
? 'socialCreditsLevelPoor'.tr()
|
||||
: credits < 150
|
||||
? 'socialCreditsLevelNormal'.tr()
|
||||
: credits < 200
|
||||
? 'socialCreditsLevelGood'.tr()
|
||||
: 'socialCreditsLevelExcellent'.tr(),
|
||||
).tr().bold().fontSize(20),
|
||||
Text(
|
||||
'${credits.toStringAsFixed(2)} pts',
|
||||
).fontSize(14),
|
||||
const Gap(8),
|
||||
LinearProgressIndicator(value: credits / 200),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Symbols.info),
|
||||
tooltip: 'socialCreditsDescription'.tr(),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: IconButton(
|
||||
onPressed: () {},
|
||||
icon: const Icon(Symbols.info),
|
||||
tooltip: 'socialCreditsDescription'.tr(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
error: (_, _) => Text('Error loading credits'),
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
)
|
||||
.padding(horizontal: 20, vertical: 16),
|
||||
),
|
||||
Expanded(
|
||||
child: PagingHelperView(
|
||||
provider: socialCreditHistoryNotifierProvider,
|
||||
futureRefreshable: socialCreditHistoryNotifierProvider.future,
|
||||
notifierRefreshable: socialCreditHistoryNotifierProvider.notifier,
|
||||
contentBuilder:
|
||||
(data, widgetCount, endItemView) => ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: widgetCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == widgetCount - 1) {
|
||||
return endItemView;
|
||||
}
|
||||
final record = data.items[index];
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
error: (_, _) => Text('Error loading credits'),
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
)
|
||||
.padding(horizontal: 20, vertical: 16),
|
||||
),
|
||||
Expanded(
|
||||
child: PagingHelperView(
|
||||
provider: socialCreditHistoryNotifierProvider,
|
||||
futureRefreshable: socialCreditHistoryNotifierProvider.future,
|
||||
notifierRefreshable: socialCreditHistoryNotifierProvider.notifier,
|
||||
contentBuilder:
|
||||
(data, widgetCount, endItemView) => ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: widgetCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == widgetCount - 1) {
|
||||
return endItemView;
|
||||
}
|
||||
final record = data.items[index];
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text(record.reason),
|
||||
subtitle: Text(
|
||||
DateFormat.yMMMd().format(record.createdAt),
|
||||
title: Text(record.reason),
|
||||
subtitle: Text(
|
||||
DateFormat.yMMMd().format(record.createdAt),
|
||||
),
|
||||
trailing: Text(
|
||||
record.delta > 0
|
||||
? '+${record.delta}'
|
||||
: '${record.delta}',
|
||||
style: TextStyle(
|
||||
color: record.delta > 0 ? Colors.green : Colors.red,
|
||||
),
|
||||
trailing: Text(
|
||||
record.delta > 0
|
||||
? '+${record.delta}'
|
||||
: '${record.delta}',
|
||||
style: TextStyle(
|
||||
color: record.delta > 0 ? Colors.green : Colors.red,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -4,12 +4,12 @@ import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/models/wallet.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/screens/account/credits.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/widgets/account/leveling_progress.dart';
|
||||
@@ -89,7 +89,7 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
length: 3,
|
||||
child: AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('levelingProgress'.tr()),
|
||||
@@ -104,6 +104,15 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
'socialCredits'.tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text(
|
||||
'stellarProgram'.tr(),
|
||||
@@ -119,6 +128,7 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
body: TabBarView(
|
||||
children: [
|
||||
_buildLevelingTab(context, ref, user.value!),
|
||||
const SocialCreditsTab(),
|
||||
_buildStellarProgramTab(context, ref),
|
||||
],
|
||||
),
|
||||
@@ -164,10 +174,33 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
),
|
||||
const SliverGap(16),
|
||||
|
||||
// Stairs visualization with fixed height and horizontal scroll
|
||||
SliverToBoxAdapter(child: _buildLevelStairs(context, currentLevel)),
|
||||
const SliverGap(24),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: currentLevel / 120,
|
||||
minHeight: 10,
|
||||
stopIndicatorRadius: 0,
|
||||
trackGap: 0,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'${'levelingProgressLevel'.tr(args: [currentLevel.toString()])} / 120',
|
||||
textAlign: TextAlign.right,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, top: 16, bottom: 12),
|
||||
),
|
||||
),
|
||||
const SliverGap(16),
|
||||
// Leveling History
|
||||
SliverToBoxAdapter(
|
||||
child: Text(
|
||||
@@ -254,126 +287,6 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLevelStairs(BuildContext context, int currentLevel) {
|
||||
const totalLevels = 14;
|
||||
const stairHeight = 20.0;
|
||||
const stairWidth = 50.0;
|
||||
const containerHeight = 280.0;
|
||||
|
||||
return Container(
|
||||
height: containerHeight,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SizedBox(
|
||||
width: (totalLevels * (stairWidth + 8)) + 40,
|
||||
height: containerHeight,
|
||||
child: CustomPaint(
|
||||
painter: LevelStairsPainter(
|
||||
currentLevel: currentLevel,
|
||||
totalLevels: totalLevels,
|
||||
primaryColor: Theme.of(context).colorScheme.primary,
|
||||
surfaceColor: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
onSurfaceColor: Theme.of(context).colorScheme.onSurface,
|
||||
stairHeight: stairHeight,
|
||||
stairWidth: stairWidth,
|
||||
),
|
||||
child: Stack(
|
||||
children: List.generate(totalLevels, (index) {
|
||||
final level = index + 1;
|
||||
final isCompleted = level <= currentLevel;
|
||||
final isCurrent = level == currentLevel;
|
||||
|
||||
// Calculate position from bottom
|
||||
final bottomPosition = 0.0;
|
||||
final leftPosition = 20.0 + (index * (stairWidth + 8));
|
||||
|
||||
// Make higher levels progressively taller
|
||||
final progressiveHeight =
|
||||
40.0 + (index * 15.0); // Base height + progressive increase
|
||||
|
||||
return Positioned(
|
||||
left: leftPosition,
|
||||
bottom: bottomPosition,
|
||||
child: Container(
|
||||
width: stairWidth,
|
||||
height: progressiveHeight,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
isCompleted
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(6),
|
||||
topRight: Radius.circular(6),
|
||||
),
|
||||
border:
|
||||
isCurrent
|
||||
? Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
)
|
||||
: null,
|
||||
boxShadow:
|
||||
isCurrent
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.3),
|
||||
blurRadius: 6,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
level.toString(),
|
||||
style: GoogleFonts.robotoMono(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
isCompleted
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (isCurrent) ...[
|
||||
const Gap(4),
|
||||
Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembershipSection(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import "dart:async";
|
||||
import "dart:convert";
|
||||
import "dart:typed_data";
|
||||
import "package:cross_file/cross_file.dart";
|
||||
import "package:easy_localization/easy_localization.dart";
|
||||
import "package:file_picker/file_picker.dart";
|
||||
import "package:flutter/material.dart";
|
||||
@@ -10,16 +12,24 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:island/database/message.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/models/file.dart";
|
||||
import "package:island/models/file_pool.dart";
|
||||
import "package:island/pods/config.dart";
|
||||
import "package:island/pods/file_pool.dart";
|
||||
import "package:island/pods/messages_notifier.dart";
|
||||
import "package:island/pods/network.dart";
|
||||
import "package:island/pods/websocket.dart";
|
||||
import "package:island/services/file.dart";
|
||||
import "package:island/screens/chat/chat.dart";
|
||||
import "package:island/services/responsive.dart";
|
||||
import "package:island/widgets/alert.dart";
|
||||
import "package:island/widgets/app_scaffold.dart";
|
||||
import "package:island/widgets/attachment_uploader.dart";
|
||||
import "package:island/widgets/chat/call_overlay.dart";
|
||||
import "package:island/widgets/chat/message_item.dart";
|
||||
import "package:island/widgets/content/attachment_preview.dart";
|
||||
import "package:island/widgets/content/cloud_files.dart";
|
||||
import "package:island/widgets/content/sheet.dart";
|
||||
import "package:island/widgets/post/compose_shared.dart";
|
||||
import "package:island/widgets/response.dart";
|
||||
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||
import "package:styled_widget/styled_widget.dart";
|
||||
@@ -464,6 +474,70 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
|
||||
const messageKeyPrefix = 'message-';
|
||||
|
||||
Future<void> uploadAttachment(int index) async {
|
||||
final attachment = attachments.value[index];
|
||||
if (attachment.isOnCloud) return;
|
||||
|
||||
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => ChatAttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
attachments: attachments.value,
|
||||
index: index,
|
||||
),
|
||||
);
|
||||
if (config == null) return;
|
||||
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw ArgumentError('Token is null');
|
||||
|
||||
try {
|
||||
// Use 'chat-upload' as temporary key for progress
|
||||
attachmentProgress.value = {
|
||||
...attachmentProgress.value,
|
||||
'chat-upload': {index: 0},
|
||||
};
|
||||
|
||||
final cloudFile =
|
||||
await putFileToCloud(
|
||||
fileData: attachment,
|
||||
atk: token,
|
||||
baseUrl: baseUrl,
|
||||
poolId: config.poolId,
|
||||
filename: attachment.data.name ?? 'Chat media',
|
||||
mimetype:
|
||||
attachment.data.mimeType ??
|
||||
ComposeLogic.getMimeTypeFromFileType(attachment.type),
|
||||
mode:
|
||||
attachment.type == UniversalFileType.file
|
||||
? FileUploadMode.generic
|
||||
: FileUploadMode.mediaSafe,
|
||||
onProgress: (progress, _) {
|
||||
attachmentProgress.value = {
|
||||
...attachmentProgress.value,
|
||||
'chat-upload': {index: progress},
|
||||
};
|
||||
},
|
||||
).future;
|
||||
|
||||
if (cloudFile == null) {
|
||||
throw ArgumentError('Failed to upload the file...');
|
||||
}
|
||||
|
||||
final clone = List.of(attachments.value);
|
||||
clone[index] = UniversalFile(data: cloudFile, type: attachment.type);
|
||||
attachments.value = clone;
|
||||
} catch (err) {
|
||||
showErrorAlert(err.toString());
|
||||
} finally {
|
||||
attachmentProgress.value = {...attachmentProgress.value}
|
||||
..remove('chat-upload');
|
||||
}
|
||||
}
|
||||
|
||||
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
|
||||
SuperListView.builder(
|
||||
listController: listController,
|
||||
@@ -779,9 +853,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
}
|
||||
},
|
||||
attachments: attachments.value,
|
||||
onUploadAttachment: (_) {
|
||||
// not going to do anything, only upload when send the message
|
||||
},
|
||||
onUploadAttachment: uploadAttachment,
|
||||
onDeleteAttachment: (index) async {
|
||||
final attachment = attachments.value[index];
|
||||
if (attachment.isOnCloud) {
|
||||
@@ -806,6 +878,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
onAttachmentsChanged: (newAttachments) {
|
||||
attachments.value = newAttachments;
|
||||
},
|
||||
attachmentProgress: attachmentProgress.value,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -825,3 +898,344 @@ class ChatRoomScreen extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatAttachmentUploaderSheet extends StatefulWidget {
|
||||
final WidgetRef ref;
|
||||
final List<UniversalFile> attachments;
|
||||
final int index;
|
||||
|
||||
const ChatAttachmentUploaderSheet({
|
||||
super.key,
|
||||
required this.ref,
|
||||
required this.attachments,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatAttachmentUploaderSheet> createState() =>
|
||||
_ChatAttachmentUploaderSheetState();
|
||||
}
|
||||
|
||||
class _ChatAttachmentUploaderSheetState
|
||||
extends State<ChatAttachmentUploaderSheet> {
|
||||
String? selectedPoolId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final attachment = widget.attachments[widget.index];
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'uploadAttachment'.tr(),
|
||||
child: FutureBuilder<List<SnFilePool>>(
|
||||
future: widget.ref.read(poolsProvider.future),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('errorLoadingPools'.tr()));
|
||||
}
|
||||
final pools = snapshot.data!.filterValid();
|
||||
selectedPoolId ??= resolveDefaultPoolId(widget.ref, pools);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
value: selectedPoolId,
|
||||
items:
|
||||
pools.map((pool) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: pool.id,
|
||||
child: Text(pool.name),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectedPoolId = value;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: 'selectPool'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'choosePool'.tr(),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
FutureBuilder<int?>(
|
||||
future: _getFileSize(attachment),
|
||||
builder: (context, sizeSnapshot) {
|
||||
if (!sizeSnapshot.hasData) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final fileSize = sizeSnapshot.data!;
|
||||
final selectedPool = pools.firstWhere(
|
||||
(p) => p.id == selectedPoolId,
|
||||
);
|
||||
|
||||
// Check file size limit
|
||||
final maxFileSize =
|
||||
selectedPool.policyConfig?['max_file_size']
|
||||
as int?;
|
||||
final fileSizeExceeded =
|
||||
maxFileSize != null && fileSize > maxFileSize;
|
||||
|
||||
// Check accepted types
|
||||
final acceptTypes =
|
||||
selectedPool.policyConfig?['accept_types']
|
||||
as List?;
|
||||
final mimeType =
|
||||
attachment.data.mimeType ??
|
||||
ComposeLogic.getMimeTypeFromFileType(
|
||||
attachment.type,
|
||||
);
|
||||
final typeAccepted =
|
||||
acceptTypes == null ||
|
||||
acceptTypes.isEmpty ||
|
||||
acceptTypes.any(
|
||||
(type) => mimeType.startsWith(type),
|
||||
);
|
||||
|
||||
final hasIssues = fileSizeExceeded || !typeAccepted;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasIssues) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.warning,
|
||||
size: 18,
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'uploadConstraints'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (fileSizeExceeded) ...[
|
||||
const Gap(4),
|
||||
Text(
|
||||
'fileSizeExceeded'.tr(
|
||||
args: [
|
||||
_formatFileSize(maxFileSize),
|
||||
],
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!typeAccepted) ...[
|
||||
const Gap(4),
|
||||
Text(
|
||||
'fileTypeNotAccepted'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
],
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
const Icon(
|
||||
Symbols.account_balance_wallet,
|
||||
size: 18,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'quotaCostInfo'.tr(
|
||||
args: [
|
||||
_formatQuotaCost(
|
||||
fileSize,
|
||||
selectedPool,
|
||||
),
|
||||
],
|
||||
),
|
||||
style:
|
||||
Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium,
|
||||
).fontSize(13),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 4),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(4),
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
const Icon(Symbols.info, size: 18),
|
||||
Text(
|
||||
'attachmentPreview'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
).fontSize(13),
|
||||
],
|
||||
).padding(horizontal: 4),
|
||||
const Gap(8),
|
||||
AttachmentPreview(item: attachment, isCompact: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Symbols.close),
|
||||
label: Text('cancel').tr(),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton.icon(
|
||||
onPressed: () => _confirmUpload(),
|
||||
icon: const Icon(Symbols.upload),
|
||||
label: Text('upload').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AttachmentUploadConfig?> _getUploadConfig() async {
|
||||
final attachment = widget.attachments[widget.index];
|
||||
final fileSize = await _getFileSize(attachment);
|
||||
|
||||
if (fileSize == null) return null;
|
||||
|
||||
// Get the selected pool to check constraints
|
||||
final pools = await widget.ref.read(poolsProvider.future);
|
||||
final selectedPool = pools.filterValid().firstWhere(
|
||||
(p) => p.id == selectedPoolId,
|
||||
);
|
||||
|
||||
// Check constraints
|
||||
final maxFileSize = selectedPool.policyConfig?['max_file_size'] as int?;
|
||||
final fileSizeExceeded = maxFileSize != null && fileSize > maxFileSize;
|
||||
|
||||
final acceptTypes = selectedPool.policyConfig?['accept_types'] as List?;
|
||||
final mimeType =
|
||||
attachment.data.mimeType ??
|
||||
ComposeLogic.getMimeTypeFromFileType(attachment.type);
|
||||
final typeAccepted =
|
||||
acceptTypes == null ||
|
||||
acceptTypes.isEmpty ||
|
||||
acceptTypes.any((type) => mimeType.startsWith(type));
|
||||
|
||||
final hasConstraints = fileSizeExceeded || !typeAccepted;
|
||||
|
||||
return AttachmentUploadConfig(
|
||||
poolId: selectedPoolId!,
|
||||
hasConstraints: hasConstraints,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmUpload() async {
|
||||
final config = await _getUploadConfig();
|
||||
if (config != null && mounted) {
|
||||
Navigator.pop(context, config);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int?> _getFileSize(UniversalFile attachment) async {
|
||||
if (attachment.data is XFile) {
|
||||
try {
|
||||
return await (attachment.data as XFile).length();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
} else if (attachment.data is SnCloudFile) {
|
||||
return (attachment.data as SnCloudFile).size;
|
||||
} else if (attachment.data is List<int>) {
|
||||
return (attachment.data as List<int>).length;
|
||||
} else if (attachment.data is Uint8List) {
|
||||
return (attachment.data as Uint8List).length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _formatNumber(int number) {
|
||||
if (number >= 1000000) {
|
||||
return '${(number / 1000000).toStringAsFixed(1)}M';
|
||||
} else if (number >= 1000) {
|
||||
return '${(number / 1000).toStringAsFixed(1)}K';
|
||||
} else {
|
||||
return number.toString();
|
||||
}
|
||||
}
|
||||
|
||||
String _formatFileSize(int bytes) {
|
||||
if (bytes >= 1073741824) {
|
||||
return '${(bytes / 1073741824).toStringAsFixed(1)} GB';
|
||||
} else if (bytes >= 1048576) {
|
||||
return '${(bytes / 1048576).toStringAsFixed(1)} MB';
|
||||
} else if (bytes >= 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
} else {
|
||||
return '$bytes bytes';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatQuotaCost(int fileSize, SnFilePool pool) {
|
||||
final costMultiplier = pool.billingConfig?['cost_multiplier'] ?? 1.0;
|
||||
final quotaCost = ((fileSize / 1024 / 1024) * costMultiplier).round();
|
||||
return _formatNumber(quotaCost);
|
||||
}
|
||||
}
|
||||
|
556
lib/screens/files/file_list.dart
Normal file
556
lib/screens/files/file_list.dart
Normal file
@@ -0,0 +1,556 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/file_pool.dart';
|
||||
import 'package:island/utils/format.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
part 'file_list.g.dart';
|
||||
|
||||
@riverpod
|
||||
class CloudFileListNotifier extends _$CloudFileListNotifier
|
||||
with CursorPagingNotifierMixin<SnCloudFile> {
|
||||
String? _poolId;
|
||||
bool _includeRecycled = false;
|
||||
|
||||
void setFilters(String? poolId, bool includeRecycled) {
|
||||
_poolId = poolId;
|
||||
_includeRecycled = includeRecycled;
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CursorPagingData<SnCloudFile>> build() => fetch(cursor: null);
|
||||
|
||||
@override
|
||||
Future<CursorPagingData<SnCloudFile>> fetch({required String? cursor}) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final offset = cursor == null ? 0 : int.parse(cursor);
|
||||
final take = 20;
|
||||
|
||||
final queryParameters = <String, dynamic>{'offset': offset, 'take': take};
|
||||
|
||||
// Add filter parameters
|
||||
if (_poolId != null) {
|
||||
queryParameters['pool'] = _poolId!;
|
||||
}
|
||||
if (_includeRecycled) {
|
||||
queryParameters['recycled'] = 'true';
|
||||
}
|
||||
|
||||
final response = await client.get(
|
||||
'/drive/files/me',
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
|
||||
final List<SnCloudFile> items =
|
||||
(response.data as List)
|
||||
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
final total = int.parse(response.headers.value('X-Total') ?? '0');
|
||||
|
||||
final hasMore = offset + items.length < total;
|
||||
final nextCursor = hasMore ? (offset + items.length).toString() : null;
|
||||
|
||||
return CursorPagingData(
|
||||
items: items,
|
||||
hasMore: hasMore,
|
||||
nextCursor: nextCursor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/drive/billing/usage');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<Map<String, dynamic>?> billingQuota(Ref ref) async {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final response = await client.get('/drive/billing/quota');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
class FileListScreen extends HookConsumerWidget {
|
||||
const FileListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Filter state
|
||||
final selectedPool = useState<String?>(null);
|
||||
final includeRecycled = useState(false);
|
||||
|
||||
final usageAsync = ref.watch(billingUsageProvider);
|
||||
final quotaAsync = ref.watch(billingQuotaProvider);
|
||||
|
||||
// Update notifier filters when state changes
|
||||
useEffect(() {
|
||||
final notifier = ref.read(cloudFileListNotifierProvider.notifier);
|
||||
notifier.setFilters(selectedPool.value, includeRecycled.value);
|
||||
return null;
|
||||
}, [selectedPool.value, includeRecycled.value]);
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(title: Text('Files'), leading: const PageBackButton()),
|
||||
body: usageAsync.when(
|
||||
data:
|
||||
(usage) => quotaAsync.when(
|
||||
data:
|
||||
(quota) => _buildQuotaUI(
|
||||
usage,
|
||||
quota,
|
||||
ref,
|
||||
selectedPool,
|
||||
includeRecycled,
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error loading quota')),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Error loading usage')),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuotaUI(
|
||||
Map<String, dynamic>? usage,
|
||||
Map<String, dynamic>? quota,
|
||||
WidgetRef ref,
|
||||
ValueNotifier<String?> selectedPool,
|
||||
ValueNotifier<bool> includeRecycled,
|
||||
) {
|
||||
if (usage == null) return const SizedBox.shrink();
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
const SliverGap(8),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'All Uploads',
|
||||
'${((usage['total_usage_bytes'] as num) / (1024 * 1024 * 1024)).toStringAsFixed(3)} GiB',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'All Files',
|
||||
'${usage['total_file_count']}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Quota',
|
||||
'${usage['total_quota']} MiB',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatCard(
|
||||
'Used Quota',
|
||||
'${((usage['used_quota'] as num) / (usage['total_quota'] as num) * 100).toStringAsFixed(2)}%',
|
||||
progress:
|
||||
(usage['used_quota'] as num) /
|
||||
(usage['total_quota'] as num),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 8),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text('Pool Usage'),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: PieChart(_buildPoolChartData(usage)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text('Verbose Quota'),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: PieChart(_buildQuotaChartData(quota)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 8),
|
||||
),
|
||||
const SliverGap(8),
|
||||
SliverToBoxAdapter(
|
||||
child: _buildFilters(ref, selectedPool, includeRecycled),
|
||||
),
|
||||
const SliverGap(8),
|
||||
PagingHelperSliverView(
|
||||
provider: cloudFileListNotifierProvider,
|
||||
futureRefreshable: cloudFileListNotifierProvider.future,
|
||||
notifierRefreshable: cloudFileListNotifierProvider.notifier,
|
||||
contentBuilder:
|
||||
(data, widgetCount, endItemView) => SliverList.builder(
|
||||
itemCount: widgetCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == widgetCount - 1) {
|
||||
return endItemView;
|
||||
}
|
||||
|
||||
final item = data.items[index];
|
||||
final itemType = item.mimeType?.split('/').firstOrNull;
|
||||
return ListTile(
|
||||
leading: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
width: 48,
|
||||
child: switch (itemType) {
|
||||
'image' => CloudImageWidget(file: item),
|
||||
'audio' =>
|
||||
const Icon(Symbols.audio_file, fill: 1).center(),
|
||||
'video' =>
|
||||
const Icon(Symbols.video_file, fill: 1).center(),
|
||||
_ =>
|
||||
const Icon(Symbols.body_system, fill: 1).center(),
|
||||
},
|
||||
),
|
||||
),
|
||||
title:
|
||||
item.name.isEmpty
|
||||
? Text('untitled').tr().italic()
|
||||
: Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(formatFileSize(item.size)),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => FileInfoSheet(item: item),
|
||||
);
|
||||
},
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Symbols.delete),
|
||||
onPressed: () async {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'confirmDeleteFile'.tr(),
|
||||
'deleteFile'.tr(),
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
if (context.mounted) showLoadingModal(context);
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/drive/files/${item.id}');
|
||||
ref.invalidate(cloudFileListNotifierProvider);
|
||||
} catch (e) {
|
||||
showSnackBar('failedToDeleteFile'.tr());
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
PieChartData _buildPoolChartData(Map<String, dynamic> usage) {
|
||||
final pools = usage['pool_usages'] as List<dynamic>;
|
||||
final colors = [
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.orange,
|
||||
Colors.red,
|
||||
Colors.purple,
|
||||
];
|
||||
return PieChartData(
|
||||
sections:
|
||||
pools.asMap().entries.map((entry) {
|
||||
final pool = entry.value as Map<String, dynamic>;
|
||||
final title = pool['pool_name'] as String;
|
||||
final truncatedTitle =
|
||||
title.length > 8 ? '${title.substring(0, 8)}...' : title;
|
||||
return PieChartSectionData(
|
||||
value: (pool['usage_bytes'] as num).toDouble(),
|
||||
title: truncatedTitle,
|
||||
color: colors[entry.key % colors.length],
|
||||
radius: 60,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
PieChartData _buildQuotaChartData(Map<String, dynamic>? quota) {
|
||||
if (quota == null) return PieChartData(sections: []);
|
||||
return PieChartData(
|
||||
sections: [
|
||||
PieChartSectionData(
|
||||
value: (quota['based_quota'] as num).toDouble(),
|
||||
title: 'Base',
|
||||
color: Colors.green,
|
||||
radius: 60,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
PieChartSectionData(
|
||||
value: (quota['extra_quota'] as num).toDouble(),
|
||||
title: 'Extra',
|
||||
color: Colors.orange,
|
||||
radius: 60,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilters(
|
||||
WidgetRef ref,
|
||||
ValueNotifier<String?> selectedPool,
|
||||
ValueNotifier<bool> includeRecycled,
|
||||
) {
|
||||
final poolsAsync = ref.watch(poolsProvider);
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'filters'.tr(),
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Gap(16),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth > 600;
|
||||
return isWide
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: poolsAsync.when(
|
||||
data:
|
||||
(pools) => DropdownButtonFormField<String?>(
|
||||
value: selectedPool.value,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Pool',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('allPools'.tr()),
|
||||
),
|
||||
...pools.map(
|
||||
(pool) => DropdownMenuItem<String?>(
|
||||
value: pool.id,
|
||||
child: Text(pool.name),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged:
|
||||
(value) => selectedPool.value = value,
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, _) => const Text('Error loading pools'),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text('includeRecycled'.tr()),
|
||||
const Gap(8),
|
||||
Switch(
|
||||
value: includeRecycled.value,
|
||||
onChanged:
|
||||
(value) => includeRecycled.value = value,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.delete_sweep),
|
||||
tooltip: 'deleteRecycledFiles'.tr(),
|
||||
onPressed:
|
||||
includeRecycled.value
|
||||
? () => _deleteRecycledFiles(ref)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
poolsAsync.when(
|
||||
data:
|
||||
(pools) => DropdownButtonFormField<String?>(
|
||||
value: selectedPool.value,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Pool',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('allPools'.tr()),
|
||||
),
|
||||
...pools.map(
|
||||
(pool) => DropdownMenuItem<String?>(
|
||||
value: pool.id,
|
||||
child: Text(pool.name),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged:
|
||||
(value) => selectedPool.value = value,
|
||||
),
|
||||
loading: () => const CircularProgressIndicator(),
|
||||
error: (e, _) => const Text('Error loading pools'),
|
||||
),
|
||||
const Gap(16),
|
||||
Row(
|
||||
children: [
|
||||
Text('includeRecycled'.tr()),
|
||||
const Gap(8),
|
||||
Switch(
|
||||
value: includeRecycled.value,
|
||||
onChanged:
|
||||
(value) => includeRecycled.value = value,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.delete_sweep),
|
||||
tooltip: 'deleteRecycledFiles'.tr(),
|
||||
onPressed:
|
||||
includeRecycled.value
|
||||
? () => _deleteRecycledFiles(ref)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
).padding(horizontal: 8);
|
||||
}
|
||||
|
||||
Future<void> _deleteRecycledFiles(WidgetRef ref) async {
|
||||
final confirmed = await showConfirmAlert(
|
||||
'confirmDeleteRecycledFiles'.tr(),
|
||||
'deleteRecycledFiles'.tr(),
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
if (ref.context.mounted) showLoadingModal(ref.context);
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.delete('/drive/files/recycled');
|
||||
ref.invalidate(cloudFileListNotifierProvider);
|
||||
showSnackBar('recycledFilesDeleted'.tr());
|
||||
} catch (e) {
|
||||
showSnackBar('failedToDeleteRecycledFiles'.tr());
|
||||
} finally {
|
||||
if (ref.context.mounted) hideLoadingModal(ref.context);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStatCard(String label, String value, {double? progress}) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 14)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (progress != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(value: progress),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
69
lib/screens/files/file_list.g.dart
Normal file
69
lib/screens/files/file_list.g.dart
Normal file
@@ -0,0 +1,69 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'file_list.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$billingUsageHash() => r'270ec8499378ee0c038aa44ad1c2e3ad9025740a';
|
||||
|
||||
/// See also [billingUsage].
|
||||
@ProviderFor(billingUsage)
|
||||
final billingUsageProvider =
|
||||
AutoDisposeFutureProvider<Map<String, dynamic>?>.internal(
|
||||
billingUsage,
|
||||
name: r'billingUsageProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$billingUsageHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef BillingUsageRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
|
||||
String _$billingQuotaHash() => r'0696b500fa8bb1270641bcacf262be58caff9b38';
|
||||
|
||||
/// See also [billingQuota].
|
||||
@ProviderFor(billingQuota)
|
||||
final billingQuotaProvider =
|
||||
AutoDisposeFutureProvider<Map<String, dynamic>?>.internal(
|
||||
billingQuota,
|
||||
name: r'billingQuotaProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$billingQuotaHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
|
||||
String _$cloudFileListNotifierHash() =>
|
||||
r'e2c8a076a9e635c7b43a87d00f78775427ba6334';
|
||||
|
||||
/// See also [CloudFileListNotifier].
|
||||
@ProviderFor(CloudFileListNotifier)
|
||||
final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
|
||||
CloudFileListNotifier,
|
||||
CursorPagingData<SnCloudFile>
|
||||
>.internal(
|
||||
CloudFileListNotifier.new,
|
||||
name: r'cloudFileListNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$cloudFileListNotifierHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$CloudFileListNotifier =
|
||||
AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFile>>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
@@ -10,6 +10,7 @@ import 'package:island/screens/creators/publishers.dart';
|
||||
import 'package:island/screens/posts/compose_article.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/attachment_uploader.dart';
|
||||
import 'package:island/widgets/content/attachment_preview.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/post/compose_shared.dart';
|
||||
@@ -225,8 +226,26 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
return AttachmentPreview(
|
||||
item: state.attachments.value[idx],
|
||||
progress: progressMap[idx],
|
||||
onRequestUpload:
|
||||
() => ComposeLogic.uploadAttachment(ref, state, idx),
|
||||
onRequestUpload: () async {
|
||||
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
state: state,
|
||||
index: idx,
|
||||
),
|
||||
);
|
||||
if (config != null) {
|
||||
await ComposeLogic.uploadAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
poolId: config.poolId,
|
||||
);
|
||||
}
|
||||
},
|
||||
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||
onUpdate:
|
||||
(value) => ComposeLogic.updateAttachment(state, value, idx),
|
||||
@@ -253,8 +272,27 @@ class PostComposeScreen extends HookConsumerWidget {
|
||||
return AttachmentPreview(
|
||||
item: state.attachments.value[idx],
|
||||
progress: progressMap[idx],
|
||||
onRequestUpload:
|
||||
() => ComposeLogic.uploadAttachment(ref, state, idx),
|
||||
onRequestUpload: () async {
|
||||
final config =
|
||||
await showModalBottomSheet<AttachmentUploadConfig>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
state: state,
|
||||
index: idx,
|
||||
),
|
||||
);
|
||||
if (config != null) {
|
||||
await ComposeLogic.uploadAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
poolId: config.poolId,
|
||||
);
|
||||
}
|
||||
},
|
||||
onDelete:
|
||||
() => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||
onUpdate:
|
||||
|
@@ -11,6 +11,7 @@ import 'package:island/models/post.dart';
|
||||
import 'package:island/screens/creators/publishers.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/attachment_uploader.dart';
|
||||
import 'package:island/screens/posts/post_detail.dart';
|
||||
import 'package:island/widgets/content/attachment_preview.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
@@ -345,12 +346,30 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
||||
isCompact: true,
|
||||
item: attachments[idx],
|
||||
progress: progressMap[idx],
|
||||
onRequestUpload:
|
||||
() => ComposeLogic.uploadAttachment(
|
||||
onRequestUpload: () async {
|
||||
final config =
|
||||
await showModalBottomSheet<
|
||||
AttachmentUploadConfig
|
||||
>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) =>
|
||||
AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
state: state,
|
||||
index: idx,
|
||||
),
|
||||
);
|
||||
if (config != null) {
|
||||
await ComposeLogic.uploadAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
),
|
||||
poolId: config.poolId,
|
||||
);
|
||||
}
|
||||
},
|
||||
onUpdate:
|
||||
(value) =>
|
||||
ComposeLogic.updateAttachment(
|
||||
|
@@ -21,7 +21,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/pool_provider.dart';
|
||||
import 'package:island/pods/file_pool.dart';
|
||||
import 'package:island/models/file_pool.dart';
|
||||
|
||||
class SettingsScreen extends HookConsumerWidget {
|
||||
@@ -146,12 +146,12 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
child: Text('Bubble').fontSize(14),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'discord',
|
||||
child: Text('Discord').fontSize(14),
|
||||
value: 'column',
|
||||
child: Text('Column').fontSize(14),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'irc',
|
||||
child: Text('IRC').fontSize(14),
|
||||
value: 'compact',
|
||||
child: Text('Compact').fontSize(14),
|
||||
),
|
||||
],
|
||||
value: settings.messageDisplayStyle,
|
||||
|
@@ -290,8 +290,9 @@ class AccountSessionSheet extends HookConsumerWidget {
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted)
|
||||
if (context.mounted) {
|
||||
hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
return confirm;
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
@@ -8,50 +7,152 @@ class LevelingProgressCard extends StatelessWidget {
|
||||
final int level;
|
||||
final int experience;
|
||||
final double progress;
|
||||
final VoidCallback? onTap;
|
||||
final bool isCompact;
|
||||
|
||||
const LevelingProgressCard({
|
||||
super.key,
|
||||
required this.level,
|
||||
required this.experience,
|
||||
required this.progress,
|
||||
this.onTap,
|
||||
this.isCompact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
// Calculate level stage (1-12, each stage covers 10 levels)
|
||||
int stage = ((level - 1) ~/ 10) + 1;
|
||||
stage = stage.clamp(1, 12); // Ensure stage is within 1-12
|
||||
|
||||
// Define colors for each stage
|
||||
const List<Color> stageColors = [
|
||||
Colors.green,
|
||||
Colors.blue,
|
||||
Colors.teal,
|
||||
Colors.cyan,
|
||||
Colors.indigo,
|
||||
Colors.lime,
|
||||
Colors.yellow,
|
||||
Colors.amber,
|
||||
Colors.orange,
|
||||
Colors.deepOrange,
|
||||
Colors.pink,
|
||||
Colors.red,
|
||||
];
|
||||
|
||||
Color stageColor = stageColors[stage - 1];
|
||||
|
||||
// Compact mode adjustments
|
||||
final double levelFontSize = isCompact ? 14 : 18;
|
||||
final double stageFontSize = isCompact ? 13 : 14;
|
||||
final double experienceFontSize = isCompact ? 12 : 14;
|
||||
final double progressHeight = isCompact ? 6 : 10;
|
||||
final double horizontalPadding = isCompact ? 16 : 20;
|
||||
final double verticalPadding = isCompact ? 12 : 16;
|
||||
final double gapSize = isCompact ? 4 : 8;
|
||||
final double rowSpacing = 12;
|
||||
|
||||
final cardContent = Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 8,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'levelingProgressLevel'.tr(args: [level.toString()]),
|
||||
style: GoogleFonts.robotoMono(),
|
||||
).fontSize(13).bold(),
|
||||
Text(
|
||||
'levelingProgressExperience'.tr(args: [experience.toString()]),
|
||||
style: GoogleFonts.robotoMono(),
|
||||
).fontSize(13),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
Tooltip(
|
||||
message: '${(progress).toStringAsFixed(1)}%',
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 4,
|
||||
value: progress / 100,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
stageColor.withOpacity(0.1),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
spacing: rowSpacing,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'levelingProgressLevel'.tr(args: [level.toString()]),
|
||||
style: TextStyle(
|
||||
color: stageColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: levelFontSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'levelingStage$stage'.tr(),
|
||||
style: TextStyle(
|
||||
color: stageColor.withOpacity(0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: stageFontSize,
|
||||
),
|
||||
),
|
||||
if (onTap != null) ...[
|
||||
const Gap(4),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: isCompact ? 10 : 12,
|
||||
color: stageColor.withOpacity(0.7),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Gap(gapSize),
|
||||
Row(
|
||||
spacing: rowSpacing,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: '${progress.toStringAsFixed(1)}%',
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: progressHeight,
|
||||
value: progress,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerLow.withOpacity(0.75),
|
||||
color: stageColor,
|
||||
stopIndicatorRadius: 0,
|
||||
trackGap: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'levelingProgressExperience'.tr(
|
||||
args: [experience.toString()],
|
||||
),
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withOpacity(0.8),
|
||||
fontSize: experienceFontSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(horizontal: horizontalPadding, vertical: verticalPadding),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return cardContent;
|
||||
}
|
||||
}
|
||||
|
365
lib/widgets/attachment_uploader.dart
Normal file
365
lib/widgets/attachment_uploader.dart
Normal file
@@ -0,0 +1,365 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/file_pool.dart';
|
||||
import 'package:island/pods/file_pool.dart';
|
||||
import 'package:island/widgets/content/attachment_preview.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/post/compose_shared.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class AttachmentUploadConfig {
|
||||
final String poolId;
|
||||
final bool hasConstraints;
|
||||
|
||||
const AttachmentUploadConfig({
|
||||
required this.poolId,
|
||||
required this.hasConstraints,
|
||||
});
|
||||
}
|
||||
|
||||
class AttachmentUploaderSheet extends StatefulWidget {
|
||||
final WidgetRef ref;
|
||||
final ComposeState state;
|
||||
final int index;
|
||||
|
||||
const AttachmentUploaderSheet({
|
||||
super.key,
|
||||
required this.ref,
|
||||
required this.state,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AttachmentUploaderSheet> createState() =>
|
||||
_AttachmentUploaderSheetState();
|
||||
}
|
||||
|
||||
class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
|
||||
String? selectedPoolId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final attachment = widget.state.attachments.value[widget.index];
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'uploadAttachment'.tr(),
|
||||
child: FutureBuilder<List<SnFilePool>>(
|
||||
future: widget.ref.read(poolsProvider.future),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(child: Text('errorLoadingPools'.tr()));
|
||||
}
|
||||
final pools = snapshot.data!.filterValid();
|
||||
selectedPoolId ??= resolveDefaultPoolId(widget.ref, pools);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
value: selectedPoolId,
|
||||
items:
|
||||
pools.map((pool) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: pool.id,
|
||||
child: Text(pool.name),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectedPoolId = value;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: 'selectPool'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'choosePool'.tr(),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
FutureBuilder<int?>(
|
||||
future: _getFileSize(attachment),
|
||||
builder: (context, sizeSnapshot) {
|
||||
if (!sizeSnapshot.hasData) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final fileSize = sizeSnapshot.data!;
|
||||
final selectedPool = pools.firstWhere(
|
||||
(p) => p.id == selectedPoolId,
|
||||
);
|
||||
|
||||
// Check file size limit
|
||||
final maxFileSize =
|
||||
selectedPool.policyConfig?['max_file_size']
|
||||
as int?;
|
||||
final fileSizeExceeded =
|
||||
maxFileSize != null && fileSize > maxFileSize;
|
||||
|
||||
// Check accepted types
|
||||
final acceptTypes =
|
||||
selectedPool.policyConfig?['accept_types']
|
||||
as List?;
|
||||
final mimeType =
|
||||
attachment.data.mimeType ??
|
||||
ComposeLogic.getMimeTypeFromFileType(
|
||||
attachment.type,
|
||||
);
|
||||
final typeAccepted =
|
||||
acceptTypes == null ||
|
||||
acceptTypes.isEmpty ||
|
||||
acceptTypes.any(
|
||||
(type) => mimeType.startsWith(type),
|
||||
);
|
||||
|
||||
final hasIssues = fileSizeExceeded || !typeAccepted;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (hasIssues) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.warning,
|
||||
size: 18,
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'uploadConstraints'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (fileSizeExceeded) ...[
|
||||
const Gap(4),
|
||||
Text(
|
||||
'fileSizeExceeded'.tr(
|
||||
args: [
|
||||
_formatFileSize(maxFileSize),
|
||||
],
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!typeAccepted) ...[
|
||||
const Gap(4),
|
||||
Text(
|
||||
'fileTypeNotAccepted'.tr(),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(12),
|
||||
],
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
const Icon(
|
||||
Symbols.account_balance_wallet,
|
||||
size: 18,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'quotaCostInfo'.tr(
|
||||
args: [
|
||||
_formatQuotaCost(
|
||||
fileSize,
|
||||
selectedPool,
|
||||
),
|
||||
],
|
||||
),
|
||||
style:
|
||||
Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium,
|
||||
).fontSize(13),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 4),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(4),
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
const Icon(Symbols.info, size: 18),
|
||||
Text(
|
||||
'attachmentPreview'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
).fontSize(13),
|
||||
],
|
||||
).padding(horizontal: 4),
|
||||
const Gap(8),
|
||||
AttachmentPreview(item: attachment, isCompact: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Symbols.close),
|
||||
label: Text('cancel').tr(),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton.icon(
|
||||
onPressed: () => _confirmUpload(),
|
||||
icon: const Icon(Symbols.upload),
|
||||
label: Text('upload').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AttachmentUploadConfig?> _getUploadConfig() async {
|
||||
final attachment = widget.state.attachments.value[widget.index];
|
||||
final fileSize = await _getFileSize(attachment);
|
||||
|
||||
if (fileSize == null) return null;
|
||||
|
||||
// Get the selected pool to check constraints
|
||||
final pools = await widget.ref.read(poolsProvider.future);
|
||||
final selectedPool = pools.filterValid().firstWhere(
|
||||
(p) => p.id == selectedPoolId,
|
||||
);
|
||||
|
||||
// Check constraints
|
||||
final maxFileSize = selectedPool.policyConfig?['max_file_size'] as int?;
|
||||
final fileSizeExceeded = maxFileSize != null && fileSize > maxFileSize;
|
||||
|
||||
final acceptTypes = selectedPool.policyConfig?['accept_types'] as List?;
|
||||
final mimeType =
|
||||
attachment.data.mimeType ??
|
||||
ComposeLogic.getMimeTypeFromFileType(attachment.type);
|
||||
final typeAccepted =
|
||||
acceptTypes == null ||
|
||||
acceptTypes.isEmpty ||
|
||||
acceptTypes.any((type) => mimeType.startsWith(type));
|
||||
|
||||
final hasConstraints = fileSizeExceeded || !typeAccepted;
|
||||
|
||||
return AttachmentUploadConfig(
|
||||
poolId: selectedPoolId!,
|
||||
hasConstraints: hasConstraints,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmUpload() async {
|
||||
final config = await _getUploadConfig();
|
||||
if (config != null && mounted) {
|
||||
Navigator.pop(context, config);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int?> _getFileSize(UniversalFile attachment) async {
|
||||
if (attachment.data is XFile) {
|
||||
try {
|
||||
return await (attachment.data as XFile).length();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
} else if (attachment.data is SnCloudFile) {
|
||||
return (attachment.data as SnCloudFile).size;
|
||||
} else if (attachment.data is List<int>) {
|
||||
return (attachment.data as List<int>).length;
|
||||
} else if (attachment.data is Uint8List) {
|
||||
return (attachment.data as Uint8List).length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _formatNumber(int number) {
|
||||
if (number >= 1000000) {
|
||||
return '${(number / 1000000).toStringAsFixed(1)}M';
|
||||
} else if (number >= 1000) {
|
||||
return '${(number / 1000).toStringAsFixed(1)}K';
|
||||
} else {
|
||||
return number.toString();
|
||||
}
|
||||
}
|
||||
|
||||
String _formatFileSize(int bytes) {
|
||||
if (bytes >= 1073741824) {
|
||||
return '${(bytes / 1073741824).toStringAsFixed(1)} GB';
|
||||
} else if (bytes >= 1048576) {
|
||||
return '${(bytes / 1048576).toStringAsFixed(1)} MB';
|
||||
} else if (bytes >= 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
} else {
|
||||
return '$bytes bytes';
|
||||
}
|
||||
}
|
||||
|
||||
String _formatQuotaCost(int fileSize, SnFilePool pool) {
|
||||
final costMultiplier = pool.billingConfig?['cost_multiplier'] ?? 1.0;
|
||||
final quotaCost = ((fileSize / 1024 / 1024) * costMultiplier).round();
|
||||
return _formatNumber(quotaCost);
|
||||
}
|
||||
}
|
@@ -32,6 +32,7 @@ class ChatInput extends HookConsumerWidget {
|
||||
final Function(int) onDeleteAttachment;
|
||||
final Function(int, int) onMoveAttachment;
|
||||
final Function(List<UniversalFile>) onAttachmentsChanged;
|
||||
final Map<String, Map<int, double>> attachmentProgress;
|
||||
|
||||
const ChatInput({
|
||||
super.key,
|
||||
@@ -48,6 +49,7 @@ class ChatInput extends HookConsumerWidget {
|
||||
required this.onDeleteAttachment,
|
||||
required this.onMoveAttachment,
|
||||
required this.onAttachmentsChanged,
|
||||
required this.attachmentProgress,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -123,6 +125,7 @@ class ChatInput extends HookConsumerWidget {
|
||||
width: 280,
|
||||
child: AttachmentPreview(
|
||||
item: attachments[idx],
|
||||
progress: attachmentProgress['chat-upload']?[idx],
|
||||
onRequestUpload: () => onUploadAttachment(idx),
|
||||
onDelete: () => onDeleteAttachment(idx),
|
||||
onUpdate: (value) {
|
||||
|
@@ -25,7 +25,7 @@ class MessageContent extends StatelessWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.delete,
|
||||
size: 14,
|
||||
size: 16,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
@@ -34,6 +34,7 @@ class MessageContent extends StatelessWidget {
|
||||
Text(
|
||||
item.content ?? 'Deleted a message',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontSize: 13,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
@@ -59,7 +60,7 @@ class MessageContent extends StatelessWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.edit,
|
||||
size: 14,
|
||||
size: 16,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
@@ -71,7 +72,7 @@ class MessageContent extends StatelessWidget {
|
||||
newText: item.content ?? 'Edited a message',
|
||||
defaultTextStyle: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall!.copyWith(
|
||||
).textTheme.bodyMedium!.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
addedTextStyle: TextStyle(
|
||||
|
@@ -54,6 +54,8 @@ class MessageItem extends HookConsumerWidget {
|
||||
required this.onJump,
|
||||
});
|
||||
|
||||
static const kFlashDuration = 300;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final remoteMessage = message.toRemoteMessage();
|
||||
@@ -106,113 +108,211 @@ class MessageItem extends HookConsumerWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'messageActions'.tr(),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (isCurrentUser)
|
||||
ListTile(
|
||||
leading: Icon(Symbols.edit),
|
||||
title: Text('edit'.tr()),
|
||||
onTap: () {
|
||||
onAction!.call(MessageItemAction.edit);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
if (isCurrentUser)
|
||||
ListTile(
|
||||
leading: Icon(Symbols.delete),
|
||||
title: Text('delete'.tr()),
|
||||
onTap: () {
|
||||
onAction!.call(MessageItemAction.delete);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
if (isCurrentUser) Divider(),
|
||||
ListTile(
|
||||
leading: Icon(Symbols.reply),
|
||||
title: Text('reply'.tr()),
|
||||
onTap: () {
|
||||
onAction!.call(MessageItemAction.reply);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Symbols.forward),
|
||||
title: Text('forward'.tr()),
|
||||
onTap: () {
|
||||
onAction!.call(MessageItemAction.forward);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
if (translatableLanguage) Divider(),
|
||||
if (translatableLanguage)
|
||||
ListTile(
|
||||
leading: Icon(Symbols.translate),
|
||||
title: Text(
|
||||
translatedText.value == null
|
||||
? 'translate'.tr()
|
||||
: translating.value
|
||||
? 'translating'.tr()
|
||||
: 'translated'.tr(),
|
||||
),
|
||||
onTap: () {
|
||||
translate();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
if (isMobile) Divider(),
|
||||
if (isMobile)
|
||||
ListTile(
|
||||
leading: Icon(Symbols.copy_all),
|
||||
title: Text('copyMessage'.tr()),
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: remoteMessage.content ?? ''),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
(context) => MessageActionSheet(
|
||||
isCurrentUser: isCurrentUser,
|
||||
onAction: onAction,
|
||||
translatableLanguage: translatableLanguage,
|
||||
translating: translating.value,
|
||||
translatedText: translatedText.value,
|
||||
translate: translate,
|
||||
isMobile: isMobile,
|
||||
remoteMessage: remoteMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
final flashing = ref.watch(
|
||||
flashingMessagesProvider.select((set) => set.contains(message.id)),
|
||||
);
|
||||
|
||||
final isFlashing = useState(false);
|
||||
final flashTimer = useState<Timer?>(null);
|
||||
|
||||
useEffect(() {
|
||||
if (flashing) {
|
||||
if (flashTimer.value != null) return null;
|
||||
isFlashing.value = true;
|
||||
flashTimer.value = Timer.periodic(
|
||||
const Duration(milliseconds: kFlashDuration),
|
||||
(timer) {
|
||||
isFlashing.value = !isFlashing.value;
|
||||
if (timer.tick >= 6) {
|
||||
// 6 ticks: 1, 0, 1, 0, 1, 0
|
||||
timer.cancel();
|
||||
flashTimer.value = null;
|
||||
isFlashing.value = false;
|
||||
ref
|
||||
.read(flashingMessagesProvider.notifier)
|
||||
.update((set) => set.difference({message.id}));
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
flashTimer.value?.cancel();
|
||||
flashTimer.value = null;
|
||||
isFlashing.value = false;
|
||||
}
|
||||
return () {
|
||||
flashTimer.value?.cancel();
|
||||
};
|
||||
}, [flashing]);
|
||||
|
||||
final flashColor =
|
||||
isFlashing.value
|
||||
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.8)
|
||||
: Colors.transparent;
|
||||
|
||||
return InkWell(
|
||||
onLongPress: showActionMenu,
|
||||
onSecondaryTap: showActionMenu,
|
||||
child: switch (settings.messageDisplayStyle) {
|
||||
'irc' => MessageItemDisplayIRC(
|
||||
message: message,
|
||||
isCurrentUser: isCurrentUser,
|
||||
progress: progress,
|
||||
showAvatar: showAvatar,
|
||||
onJump: onJump,
|
||||
translatedText: translatedText.value,
|
||||
translating: translating.value,
|
||||
),
|
||||
'discord' => MessageItemDisplayDiscord(
|
||||
message: message,
|
||||
isCurrentUser: isCurrentUser,
|
||||
progress: progress,
|
||||
showAvatar: showAvatar,
|
||||
onJump: onJump,
|
||||
translatedText: translatedText.value,
|
||||
translating: translating.value,
|
||||
),
|
||||
_ => MessageItemDisplayBubble(
|
||||
message: message,
|
||||
isCurrentUser: isCurrentUser,
|
||||
progress: progress,
|
||||
showAvatar: showAvatar,
|
||||
onJump: onJump,
|
||||
translatedText: translatedText.value,
|
||||
translating: translating.value,
|
||||
),
|
||||
onTap: () {
|
||||
// Jump to related message
|
||||
if (['messages.update', 'messages.delete'].contains(message.type) &&
|
||||
message.meta['message_id'] is String &&
|
||||
message.meta['message_id'] != null) {
|
||||
onJump(message.meta['message_id']);
|
||||
}
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
curve: Curves.easeInOut,
|
||||
duration: const Duration(milliseconds: kFlashDuration),
|
||||
decoration: BoxDecoration(color: flashColor),
|
||||
child: switch (settings.messageDisplayStyle) {
|
||||
'compact' => MessageItemDisplayIRC(
|
||||
message: message,
|
||||
isCurrentUser: isCurrentUser,
|
||||
progress: progress,
|
||||
showAvatar: showAvatar,
|
||||
onJump: onJump,
|
||||
translatedText: translatedText.value,
|
||||
translating: translating.value,
|
||||
),
|
||||
'column' => MessageItemDisplayDiscord(
|
||||
message: message,
|
||||
isCurrentUser: isCurrentUser,
|
||||
progress: progress,
|
||||
showAvatar: showAvatar,
|
||||
onJump: onJump,
|
||||
translatedText: translatedText.value,
|
||||
translating: translating.value,
|
||||
),
|
||||
_ => MessageItemDisplayBubble(
|
||||
message: message,
|
||||
isCurrentUser: isCurrentUser,
|
||||
progress: progress,
|
||||
showAvatar: showAvatar,
|
||||
onJump: onJump,
|
||||
translatedText: translatedText.value,
|
||||
translating: translating.value,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageActionSheet extends StatelessWidget {
|
||||
final bool isCurrentUser;
|
||||
final Function(String action)? onAction;
|
||||
final bool translatableLanguage;
|
||||
final bool translating;
|
||||
final String? translatedText;
|
||||
final VoidCallback translate;
|
||||
final bool isMobile;
|
||||
final dynamic remoteMessage;
|
||||
|
||||
const MessageActionSheet({
|
||||
super.key,
|
||||
required this.isCurrentUser,
|
||||
required this.onAction,
|
||||
required this.translatableLanguage,
|
||||
required this.translating,
|
||||
required this.translatedText,
|
||||
required this.translate,
|
||||
required this.isMobile,
|
||||
required this.remoteMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SheetScaffold(
|
||||
titleText: 'messageActions'.tr(),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
const Gap(4),
|
||||
if (isCurrentUser)
|
||||
ListTile(
|
||||
leading: Icon(Symbols.edit),
|
||||
title: Text('edit'.tr()),
|
||||
minTileHeight: 48,
|
||||
onTap: () {
|
||||
onAction!.call(MessageItemAction.edit);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
if (isCurrentUser)
|
||||
ListTile(
|
||||
leading: Icon(Symbols.delete),
|
||||
title: Text('delete'.tr()),
|
||||
minTileHeight: 48,
|
||||
onTap: () {
|
||||
onAction!.call(MessageItemAction.delete);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
if (isCurrentUser) const Divider(height: 8),
|
||||
ListTile(
|
||||
leading: Icon(Symbols.reply),
|
||||
title: Text('reply'.tr()),
|
||||
minTileHeight: 48,
|
||||
onTap: () {
|
||||
onAction!.call(MessageItemAction.reply);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Symbols.forward),
|
||||
title: Text('forward'.tr()),
|
||||
minTileHeight: 48,
|
||||
onTap: () {
|
||||
onAction!.call(MessageItemAction.forward);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
if (translatableLanguage) const Divider(height: 8),
|
||||
if (translatableLanguage)
|
||||
ListTile(
|
||||
leading: Icon(Symbols.translate),
|
||||
minTileHeight: 48,
|
||||
title: Text(
|
||||
translatedText == null
|
||||
? 'translate'.tr()
|
||||
: translating
|
||||
? 'translating'.tr()
|
||||
: 'translated'.tr(),
|
||||
),
|
||||
onTap: () {
|
||||
translate();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
if (isMobile) const Divider(height: 8),
|
||||
if (isMobile)
|
||||
ListTile(
|
||||
leading: Icon(Symbols.copy_all),
|
||||
title: Text('copyMessage'.tr()),
|
||||
minTileHeight: 48,
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: remoteMessage.content ?? ''),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -243,54 +343,10 @@ class MessageItemDisplayBubble extends HookConsumerWidget {
|
||||
isCurrentUser
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
final containerColor =
|
||||
isCurrentUser
|
||||
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.5)
|
||||
: Theme.of(context).colorScheme.surfaceContainer;
|
||||
|
||||
final hasBackground =
|
||||
ref.watch(backgroundImageFileProvider).valueOrNull != null;
|
||||
|
||||
final flashing = ref.watch(
|
||||
flashingMessagesProvider.select((set) => set.contains(message.id)),
|
||||
);
|
||||
|
||||
final isFlashing = useState(false);
|
||||
final flashTimer = useState<Timer?>(null);
|
||||
|
||||
useEffect(() {
|
||||
if (flashing) {
|
||||
if (flashTimer.value != null) return null;
|
||||
isFlashing.value = true;
|
||||
flashTimer.value = Timer.periodic(const Duration(milliseconds: 200), (
|
||||
timer,
|
||||
) {
|
||||
isFlashing.value = !isFlashing.value;
|
||||
if (timer.tick >= 4) {
|
||||
// 4 ticks: true, false, true, false
|
||||
timer.cancel();
|
||||
flashTimer.value = null;
|
||||
isFlashing.value = false;
|
||||
ref
|
||||
.read(flashingMessagesProvider.notifier)
|
||||
.update((set) => set.difference({message.id}));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
flashTimer.value?.cancel();
|
||||
flashTimer.value = null;
|
||||
isFlashing.value = false;
|
||||
}
|
||||
return () {
|
||||
flashTimer.value?.cancel();
|
||||
};
|
||||
}, [flashing]);
|
||||
|
||||
final flashColor =
|
||||
isFlashing.value
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.8)
|
||||
: containerColor;
|
||||
|
||||
final remoteMessage = message.toRemoteMessage();
|
||||
final sender = remoteMessage.sender;
|
||||
|
||||
@@ -321,109 +377,98 @@ class MessageItemDisplayBubble extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Flexible(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: flashColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (remoteMessage.repliedMessageId != null)
|
||||
MessageQuoteWidget(
|
||||
message: message,
|
||||
textColor: textColor,
|
||||
isReply: true,
|
||||
).padding(vertical: 4),
|
||||
if (remoteMessage.forwardedMessageId != null)
|
||||
MessageQuoteWidget(
|
||||
message: message,
|
||||
textColor: textColor,
|
||||
isReply: false,
|
||||
).padding(vertical: 4),
|
||||
if (MessageContent.hasContent(remoteMessage))
|
||||
MessageContent(
|
||||
item: remoteMessage,
|
||||
translatedText: translatedText,
|
||||
),
|
||||
if (remoteMessage.attachments.isNotEmpty)
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return CloudFileList(
|
||||
files: remoteMessage.attachments,
|
||||
maxWidth: constraints.maxWidth,
|
||||
padding: EdgeInsets.symmetric(vertical: 4),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (remoteMessage.meta['embeds'] != null)
|
||||
...((remoteMessage.meta['embeds'] as List<dynamic>)
|
||||
.map((embed) => convertMapKeysToSnakeCase(embed))
|
||||
.where((embed) => embed['type'] == 'link')
|
||||
.map((embed) => SnScrappedLink.fromJson(embed))
|
||||
.map(
|
||||
(link) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return EmbedLinkWidget(
|
||||
link: link,
|
||||
maxWidth: math.min(
|
||||
constraints.maxWidth,
|
||||
480,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList()),
|
||||
if (progress != null && progress!.isNotEmpty)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if ((remoteMessage.content?.isNotEmpty ?? false))
|
||||
const Gap(0),
|
||||
for (var entry in progress!.entries)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'fileUploadingProgress'.tr(
|
||||
args: [
|
||||
(entry.key + 1).toString(),
|
||||
entry.value.toStringAsFixed(1),
|
||||
],
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: textColor.withOpacity(0.8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (remoteMessage.repliedMessageId != null)
|
||||
MessageQuoteWidget(
|
||||
message: message,
|
||||
textColor: textColor,
|
||||
isReply: true,
|
||||
).padding(vertical: 4),
|
||||
if (remoteMessage.forwardedMessageId != null)
|
||||
MessageQuoteWidget(
|
||||
message: message,
|
||||
textColor: textColor,
|
||||
isReply: false,
|
||||
).padding(vertical: 4),
|
||||
if (MessageContent.hasContent(remoteMessage))
|
||||
MessageContent(
|
||||
item: remoteMessage,
|
||||
translatedText: translatedText,
|
||||
),
|
||||
if (remoteMessage.attachments.isNotEmpty)
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return CloudFileList(
|
||||
files: remoteMessage.attachments,
|
||||
maxWidth: constraints.maxWidth,
|
||||
padding: EdgeInsets.symmetric(vertical: 4),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (remoteMessage.meta['embeds'] != null)
|
||||
...((remoteMessage.meta['embeds'] as List<dynamic>)
|
||||
.map((embed) => convertMapKeysToSnakeCase(embed))
|
||||
.where((embed) => embed['type'] == 'link')
|
||||
.map((embed) => SnScrappedLink.fromJson(embed))
|
||||
.map(
|
||||
(link) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return EmbedLinkWidget(
|
||||
link: link,
|
||||
maxWidth: math.min(
|
||||
constraints.maxWidth,
|
||||
480,
|
||||
),
|
||||
const Gap(4),
|
||||
LinearProgressIndicator(
|
||||
value: entry.value / 100,
|
||||
backgroundColor:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceVariant,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList()),
|
||||
if (progress != null && progress!.isNotEmpty)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if ((remoteMessage.content?.isNotEmpty ?? false))
|
||||
const Gap(0),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
for (var entry in progress!.entries)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'fileUploadingProgress'.tr(
|
||||
args: [
|
||||
(entry.key + 1).toString(),
|
||||
entry.value.toStringAsFixed(1),
|
||||
],
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: textColor.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
LinearProgressIndicator(
|
||||
value: entry.value / 100,
|
||||
backgroundColor:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceVariant,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(0),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
MessageIndicators(
|
||||
@@ -467,24 +512,125 @@ class MessageItemDisplayIRC extends HookConsumerWidget {
|
||||
final sender = remoteMessage.sender;
|
||||
final textColor = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||
|
||||
final isMultiline =
|
||||
message.type == 'text' ||
|
||||
message.repliedMessageId != null ||
|
||||
message.forwardedMessageId != null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Row(
|
||||
crossAxisAlignment:
|
||||
isMultiline ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('HH:mm').format(message.createdAt),
|
||||
style: TextStyle(color: textColor.withOpacity(0.7), fontSize: 12),
|
||||
).padding(top: isMultiline ? 2 : 0),
|
||||
AccountPfcGestureDetector(
|
||||
uname: sender.account.name,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ProfilePictureWidget(
|
||||
file: sender.account.profile.picture,
|
||||
radius: 8,
|
||||
).padding(horizontal: 6, top: isMultiline ? 2 : 0),
|
||||
Text(
|
||||
sender.account.nick,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'<${sender.account.nick}>',
|
||||
style: TextStyle(color: Colors.blue),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
translatedText ?? remoteMessage.content ?? '',
|
||||
style: TextStyle(color: textColor),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (remoteMessage.repliedMessageId != null)
|
||||
MessageQuoteWidget(
|
||||
message: message,
|
||||
textColor: textColor,
|
||||
isReply: true,
|
||||
).padding(vertical: 4),
|
||||
if (remoteMessage.forwardedMessageId != null)
|
||||
MessageQuoteWidget(
|
||||
message: message,
|
||||
textColor: textColor,
|
||||
isReply: false,
|
||||
).padding(vertical: 4),
|
||||
if (MessageContent.hasContent(remoteMessage))
|
||||
MessageContent(
|
||||
item: remoteMessage,
|
||||
translatedText: translatedText,
|
||||
),
|
||||
if (remoteMessage.attachments.isNotEmpty)
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return CloudFileList(
|
||||
files: remoteMessage.attachments,
|
||||
maxWidth: constraints.maxWidth,
|
||||
padding: EdgeInsets.symmetric(vertical: 4),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (remoteMessage.meta['embeds'] != null)
|
||||
...((remoteMessage.meta['embeds'] as List<dynamic>)
|
||||
.map((embed) => convertMapKeysToSnakeCase(embed))
|
||||
.where((embed) => embed['type'] == 'link')
|
||||
.map((embed) => SnScrappedLink.fromJson(embed))
|
||||
.map(
|
||||
(link) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return EmbedLinkWidget(
|
||||
link: link,
|
||||
maxWidth: math.min(constraints.maxWidth, 480),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList()),
|
||||
if (progress != null && progress!.isNotEmpty)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if ((remoteMessage.content?.isNotEmpty ?? false))
|
||||
const SizedBox.shrink(),
|
||||
for (var entry in progress!.entries)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'fileUploadingProgress'.tr(
|
||||
args: [
|
||||
(entry.key + 1).toString(),
|
||||
entry.value.toStringAsFixed(1),
|
||||
],
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: textColor.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
LinearProgressIndicator(
|
||||
value: entry.value / 100,
|
||||
backgroundColor:
|
||||
Theme.of(context).colorScheme.surfaceVariant,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -635,7 +781,6 @@ class MessageItemDisplayDiscord extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(0),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
@@ -18,7 +18,7 @@ import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||
import "package:styled_widget/styled_widget.dart";
|
||||
import "package:super_sliver_list/super_sliver_list.dart";
|
||||
import "package:material_symbols_icons/symbols.dart";
|
||||
import "package:riverpod_annotation/riverpod_annotation.dart";
|
||||
|
||||
import "package:island/screens/chat/chat.dart";
|
||||
|
||||
class PublicRoomPreview extends HookConsumerWidget {
|
||||
|
@@ -470,7 +470,8 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
if (onRequestUpload != null)
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => onRequestUpload?.call(),
|
||||
onTap:
|
||||
item.isOnCloud ? null : () => onRequestUpload?.call(),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
|
@@ -1,15 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||
import 'package:file_saver/file_saver.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:gal/gal.dart';
|
||||
@@ -17,11 +14,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/utils/format.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||
import 'package:island/widgets/content/sensitive.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:path/path.dart' show extension;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -361,284 +357,11 @@ class CloudFileZoomIn extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
void showInfoSheet() {
|
||||
final theme = Theme.of(context);
|
||||
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
titleText: 'File Information',
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('mimeType').tr(),
|
||||
Text(
|
||||
item.mimeType ?? 'unknown'.tr(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 28, child: const VerticalDivider()),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('fileSize').tr(),
|
||||
Text(
|
||||
formatFileSize(item.size),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.hash != null)
|
||||
SizedBox(height: 28, child: const VerticalDivider()),
|
||||
if (item.hash != null)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('fileHash').tr(),
|
||||
Text(
|
||||
'${item.hash!.substring(0, 6)}...',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
onLongPress: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: item.hash!),
|
||||
);
|
||||
showSnackBar('File hash copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.tag),
|
||||
title: Text('ID').tr(),
|
||||
subtitle: Text(
|
||||
item.id,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: item.id));
|
||||
showSnackBar('File ID copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.file_present),
|
||||
title: Text('Name').tr(),
|
||||
subtitle: Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: item.name));
|
||||
showSnackBar('File name copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
if (exifData.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
Theme(
|
||||
data: theme.copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title: Text(
|
||||
'exifData'.tr(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...exifData.entries.map(
|
||||
(entry) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title:
|
||||
Text(
|
||||
entry.key.contains('-')
|
||||
? entry.key.split('-').last
|
||||
: entry.key,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).bold(),
|
||||
subtitle: Text(
|
||||
'${entry.value}'.isNotEmpty
|
||||
? '${entry.value}'
|
||||
: 'N/A',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: '${entry.value}'),
|
||||
);
|
||||
showSnackBar('Value copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (item.fileMeta != null && item.fileMeta!.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
Theme(
|
||||
data: theme.copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title: Text(
|
||||
'File Metadata',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...item.fileMeta!.entries.map(
|
||||
(entry) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title:
|
||||
Text(
|
||||
entry.key,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).bold(),
|
||||
subtitle: Text(
|
||||
jsonEncode(entry.value),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: jsonEncode(entry.value),
|
||||
),
|
||||
);
|
||||
showSnackBar('Value copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (item.userMeta != null && item.userMeta!.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
Theme(
|
||||
data: theme.copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title: Text(
|
||||
'User Metadata',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...item.userMeta!.entries.map(
|
||||
(entry) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title:
|
||||
Text(
|
||||
entry.key,
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).bold(),
|
||||
subtitle: Text(
|
||||
jsonEncode(entry.value),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: jsonEncode(entry.value),
|
||||
),
|
||||
);
|
||||
showSnackBar('Value copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (context) => FileInfoSheet(item: item),
|
||||
);
|
||||
}
|
||||
|
||||
|
280
lib/widgets/content/file_info_sheet.dart
Normal file
280
lib/widgets/content/file_info_sheet.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/utils/format.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class FileInfoSheet extends StatelessWidget {
|
||||
final SnCloudFile item;
|
||||
|
||||
const FileInfoSheet({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'File Information',
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('mimeType').tr(),
|
||||
Text(
|
||||
item.mimeType ?? 'unknown'.tr(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 28, child: const VerticalDivider()),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('fileSize').tr(),
|
||||
Text(
|
||||
formatFileSize(item.size),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.hash != null)
|
||||
SizedBox(height: 28, child: const VerticalDivider()),
|
||||
if (item.hash != null)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('fileHash').tr(),
|
||||
Text(
|
||||
'${item.hash!.substring(0, 6)}...',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onLongPress: () {
|
||||
Clipboard.setData(ClipboardData(text: item.hash!));
|
||||
showSnackBar('File hash copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.tag),
|
||||
title: Text('ID').tr(),
|
||||
subtitle: Text(
|
||||
item.id,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: item.id));
|
||||
showSnackBar('File ID copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.file_present),
|
||||
title: Text('Name').tr(),
|
||||
subtitle: Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: item.name));
|
||||
showSnackBar('File name copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
if (exifData.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
Theme(
|
||||
data: theme.copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text(
|
||||
'exifData'.tr(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...exifData.entries.map(
|
||||
(entry) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title:
|
||||
Text(
|
||||
entry.key.contains('-')
|
||||
? entry.key.split('-').last
|
||||
: entry.key,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).bold(),
|
||||
subtitle: Text(
|
||||
'${entry.value}'.isNotEmpty
|
||||
? '${entry.value}'
|
||||
: 'N/A',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: '${entry.value}'),
|
||||
);
|
||||
showSnackBar('Value copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (item.fileMeta != null && item.fileMeta!.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
Theme(
|
||||
data: theme.copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text(
|
||||
'File Metadata',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...item.fileMeta!.entries.map(
|
||||
(entry) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title:
|
||||
Text(
|
||||
entry.key,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).bold(),
|
||||
subtitle: Text(
|
||||
jsonEncode(entry.value),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: jsonEncode(entry.value)),
|
||||
);
|
||||
showSnackBar('Value copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (item.userMeta != null && item.userMeta!.isNotEmpty) ...[
|
||||
const Divider(height: 1),
|
||||
Theme(
|
||||
data: theme.copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text(
|
||||
'User Metadata',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...item.userMeta!.entries.map(
|
||||
(entry) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
),
|
||||
title:
|
||||
Text(
|
||||
entry.key,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).bold(),
|
||||
subtitle: Text(
|
||||
jsonEncode(entry.value),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: jsonEncode(entry.value)),
|
||||
);
|
||||
showSnackBar('Value copied to clipboard');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -64,6 +64,7 @@ class DebugSheet extends HookConsumerWidget {
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'Debug',
|
||||
heightFactor: 0.6,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
|
@@ -30,10 +30,13 @@ class ComposeEmbedSheet extends HookConsumerWidget {
|
||||
final selectedRenderer = useState<PostEmbedViewRenderer>(
|
||||
PostEmbedViewRenderer.webView,
|
||||
);
|
||||
final tabController = useTabController(initialLength: 2);
|
||||
final iframeController = useTextEditingController();
|
||||
|
||||
void clearForm() {
|
||||
uriController.clear();
|
||||
aspectRatioController.clear();
|
||||
iframeController.clear();
|
||||
selectedRenderer.value = PostEmbedViewRenderer.webView;
|
||||
}
|
||||
|
||||
@@ -77,6 +80,57 @@ class ComposeEmbedSheet extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void parseIframe() {
|
||||
final iframe = iframeController.text.trim();
|
||||
if (iframe.isEmpty) return;
|
||||
|
||||
final srcMatch = RegExp(r'src="([^"]*)"').firstMatch(iframe);
|
||||
final widthMatch = RegExp(r'width="([^"]*)"').firstMatch(iframe);
|
||||
final heightMatch = RegExp(r'height="([^"]*)"').firstMatch(iframe);
|
||||
|
||||
if (srcMatch != null) {
|
||||
uriController.text = srcMatch.group(1)!;
|
||||
}
|
||||
|
||||
if (widthMatch != null && heightMatch != null) {
|
||||
final w = double.tryParse(widthMatch.group(1)!);
|
||||
final h = double.tryParse(heightMatch.group(1)!);
|
||||
if (w != null && h != null && h != 0) {
|
||||
aspectRatioController.text = (w / h).toStringAsFixed(3);
|
||||
}
|
||||
}
|
||||
|
||||
tabController.animateTo(1);
|
||||
}
|
||||
|
||||
void deleteEmbed(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(dialogContext) => AlertDialog(
|
||||
title: Text('deleteEmbed').tr(),
|
||||
content: Text('deleteEmbedConfirm').tr(),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: Text('cancel').tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ComposeLogic.deleteEmbedView(state);
|
||||
clearForm();
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: Text('delete').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'embedView'.tr(),
|
||||
heightFactor: 0.7,
|
||||
@@ -85,7 +139,7 @@ class ComposeEmbedSheet extends HookConsumerWidget {
|
||||
// Header with save button when editing
|
||||
if (currentEmbedView != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -97,187 +151,207 @@ class ComposeEmbedSheet extends HookConsumerWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: saveEmbedView,
|
||||
style: ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
child: Text('save'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Tab bar
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
tabs: [Tab(text: 'auto'.tr()), Tab(text: 'manual'.tr())],
|
||||
),
|
||||
|
||||
// Content area
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Form fields
|
||||
TextField(
|
||||
controller: uriController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'embedUri'.tr(),
|
||||
hintText: 'https://example.com',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
// Auto tab
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: iframeController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'iframeCode'.tr(),
|
||||
hintText: 'iframeCodeHint'.tr(),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
maxLines: 5,
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const Gap(16),
|
||||
TextField(
|
||||
controller: aspectRatioController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'aspectRatio'.tr(),
|
||||
hintText: '16/9 = 1.777',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
const Gap(16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: parseIframe,
|
||||
icon: const Icon(Symbols.auto_fix),
|
||||
label: Text('parseIframe'.tr()),
|
||||
),
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')),
|
||||
],
|
||||
),
|
||||
const Gap(16),
|
||||
DropdownButtonFormField2<PostEmbedViewRenderer>(
|
||||
value: selectedRenderer.value,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'renderer'.tr(),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
items:
|
||||
PostEmbedViewRenderer.values.map((renderer) {
|
||||
return DropdownMenuItem(
|
||||
value: renderer,
|
||||
child: Text(renderer.name).tr(),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
selectedRenderer.value = value;
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// Current embed view display (when exists)
|
||||
if (currentEmbedView != null) ...[
|
||||
const Gap(32),
|
||||
Text(
|
||||
'currentEmbed'.tr(),
|
||||
style: theme.textTheme.titleMedium,
|
||||
).padding(horizontal: 4),
|
||||
const Gap(8),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 12,
|
||||
top: 4,
|
||||
),
|
||||
// Manual tab
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Form fields
|
||||
TextField(
|
||||
controller: uriController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'embedUri'.tr(),
|
||||
hintText: 'https://example.com',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const Gap(16),
|
||||
TextField(
|
||||
controller: aspectRatioController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'aspectRatio'.tr(),
|
||||
hintText: '16/9 = 1.777',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(r'^\d*\.?\d*$'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(16),
|
||||
DropdownButtonFormField2<PostEmbedViewRenderer>(
|
||||
value: selectedRenderer.value,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'renderer'.tr(),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
selectedItemBuilder: (context) {
|
||||
return PostEmbedViewRenderer.values.map((renderer) {
|
||||
return Text(renderer.name).tr();
|
||||
}).toList();
|
||||
},
|
||||
menuItemStyleData: MenuItemStyleData(
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
items:
|
||||
PostEmbedViewRenderer.values.map((renderer) {
|
||||
return DropdownMenuItem(
|
||||
value: renderer,
|
||||
child: Text(
|
||||
renderer.name,
|
||||
).tr().padding(horizontal: 20),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
selectedRenderer.value = value;
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// Current embed view display (when exists)
|
||||
if (currentEmbedView != null) ...[
|
||||
const Gap(32),
|
||||
Text(
|
||||
'currentEmbed'.tr(),
|
||||
style: theme.textTheme.titleMedium,
|
||||
).padding(horizontal: 4),
|
||||
const Gap(8),
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHigh,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 12,
|
||||
top: 4,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
currentEmbedView.renderer ==
|
||||
PostEmbedViewRenderer.webView
|
||||
? Symbols.web
|
||||
: Symbols.web,
|
||||
color: colorScheme.primary,
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
currentEmbedView.renderer ==
|
||||
PostEmbedViewRenderer.webView
|
||||
? Symbols.web
|
||||
: Symbols.web,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentEmbedView.uri,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.delete),
|
||||
onPressed: () => deleteEmbed(context),
|
||||
tooltip: 'delete'.tr(),
|
||||
color: colorScheme.error,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentEmbedView.uri,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Text(
|
||||
'aspectRatio'.tr(),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.delete),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(dialogContext) => AlertDialog(
|
||||
title: Text('deleteEmbed').tr(),
|
||||
content:
|
||||
Text('deleteEmbedConfirm').tr(),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
() =>
|
||||
Navigator.of(
|
||||
dialogContext,
|
||||
).pop(),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ComposeLogic.deleteEmbedView(
|
||||
state,
|
||||
);
|
||||
clearForm();
|
||||
Navigator.of(
|
||||
dialogContext,
|
||||
).pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor:
|
||||
colorScheme.error,
|
||||
),
|
||||
child: Text('delete').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'delete'.tr(),
|
||||
color: colorScheme.error,
|
||||
const Gap(4),
|
||||
Text(
|
||||
currentEmbedView.aspectRatio != null
|
||||
? currentEmbedView.aspectRatio!
|
||||
.toStringAsFixed(2)
|
||||
: 'notSet'.tr(),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
Text(
|
||||
'aspectRatio'.tr(),
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
currentEmbedView.aspectRatio != null
|
||||
? currentEmbedView.aspectRatio!
|
||||
.toStringAsFixed(2)
|
||||
: 'notSet'.tr(),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// Save button for new embed
|
||||
const Gap(16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: saveEmbedView,
|
||||
icon: const Icon(Symbols.add),
|
||||
label: Text('addEmbed'.tr()),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
const Gap(16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: saveEmbedView,
|
||||
icon: const Icon(Symbols.add),
|
||||
label: Text('addEmbed'.tr()),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@@ -20,7 +20,7 @@ import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/post/compose_link_attachments.dart';
|
||||
import 'package:island/widgets/post/compose_poll.dart';
|
||||
import 'package:island/widgets/post/compose_recorder.dart';
|
||||
import 'package:island/pods/pool_provider.dart';
|
||||
import 'package:island/pods/file_pool.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:textfield_tags/textfield_tags.dart';
|
||||
import 'dart:async';
|
||||
@@ -672,7 +672,7 @@ class ComposeLogic {
|
||||
try {
|
||||
state.submitting.value = true;
|
||||
|
||||
// Upload any local attachments first
|
||||
// pload any local attachments first
|
||||
await Future.wait(
|
||||
state.attachments.value
|
||||
.asMap()
|
||||
|
@@ -82,75 +82,83 @@ class ComposeToolbar extends HookConsumerWidget {
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: pickPhotoMedia,
|
||||
tooltip: 'addPhoto'.tr(),
|
||||
icon: const Icon(Symbols.add_a_photo),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: pickVideoMedia,
|
||||
tooltip: 'addVideo'.tr(),
|
||||
icon: const Icon(Symbols.videocam),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: addAudio,
|
||||
tooltip: 'addAudio'.tr(),
|
||||
icon: const Icon(Symbols.mic),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: pickGeneralFile,
|
||||
tooltip: 'uploadFile'.tr(),
|
||||
icon: const Icon(Symbols.file_upload),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: linkAttachment,
|
||||
icon: const Icon(Symbols.attach_file),
|
||||
tooltip: 'linkAttachment'.tr(),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
// Poll button with visual state when a poll is linked
|
||||
ListenableBuilder(
|
||||
listenable: state.pollId,
|
||||
builder: (context, _) {
|
||||
return IconButton(
|
||||
onPressed: pickPoll,
|
||||
icon: const Icon(Symbols.how_to_vote),
|
||||
tooltip: 'poll'.tr(),
|
||||
color: colorScheme.primary,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
state.pollId.value != null
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: null,
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: pickPhotoMedia,
|
||||
tooltip: 'addPhoto'.tr(),
|
||||
icon: const Icon(Symbols.add_a_photo),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Embed button with visual state when embed is present
|
||||
ListenableBuilder(
|
||||
listenable: state.embedView,
|
||||
builder: (context, _) {
|
||||
return IconButton(
|
||||
onPressed: showEmbedSheet,
|
||||
icon: const Icon(Symbols.web),
|
||||
tooltip: 'embedView'.tr(),
|
||||
color: colorScheme.primary,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
state.embedView.value != null
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: null,
|
||||
IconButton(
|
||||
onPressed: pickVideoMedia,
|
||||
tooltip: 'addVideo'.tr(),
|
||||
icon: const Icon(Symbols.videocam),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
IconButton(
|
||||
onPressed: addAudio,
|
||||
tooltip: 'addAudio'.tr(),
|
||||
icon: const Icon(Symbols.mic),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: pickGeneralFile,
|
||||
tooltip: 'uploadFile'.tr(),
|
||||
icon: const Icon(Symbols.file_upload),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: linkAttachment,
|
||||
icon: const Icon(Symbols.attach_file),
|
||||
tooltip: 'linkAttachment'.tr(),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
// Poll button with visual state when a poll is linked
|
||||
ListenableBuilder(
|
||||
listenable: state.pollId,
|
||||
builder: (context, _) {
|
||||
return IconButton(
|
||||
onPressed: pickPoll,
|
||||
icon: const Icon(Symbols.how_to_vote),
|
||||
tooltip: 'poll'.tr(),
|
||||
color: colorScheme.primary,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
state.pollId.value != null
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Embed button with visual state when embed is present
|
||||
ListenableBuilder(
|
||||
listenable: state.embedView,
|
||||
builder: (context, _) {
|
||||
return IconButton(
|
||||
onPressed: showEmbedSheet,
|
||||
icon: const Icon(Symbols.iframe),
|
||||
tooltip: 'embedView'.tr(),
|
||||
color: colorScheme.primary,
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
state.embedView.value != null
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (originalPost == null && state.isEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.draft),
|
||||
|
@@ -1,3 +1,6 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
@@ -58,8 +61,8 @@ class EmbedViewRenderer extends HookConsumerWidget {
|
||||
children: [
|
||||
Icon(
|
||||
embedView.renderer == PostEmbedViewRenderer.webView
|
||||
? Symbols.web
|
||||
: Symbols.web,
|
||||
? Symbols.globe
|
||||
: Symbols.iframe,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
@@ -74,13 +77,13 @@ class EmbedViewRenderer extends HookConsumerWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
InkWell(
|
||||
child: Icon(
|
||||
Symbols.open_in_new,
|
||||
size: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () async {
|
||||
onTap: () async {
|
||||
final uri = Uri.parse(embedView.uri);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(
|
||||
@@ -89,10 +92,6 @@ class EmbedViewRenderer extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Open in browser',
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -106,6 +105,20 @@ class EmbedViewRenderer extends HookConsumerWidget {
|
||||
? Stack(
|
||||
children: [
|
||||
InAppWebView(
|
||||
gestureRecognizers: {
|
||||
Factory<VerticalDragGestureRecognizer>(
|
||||
() => VerticalDragGestureRecognizer(),
|
||||
),
|
||||
Factory<HorizontalDragGestureRecognizer>(
|
||||
() => HorizontalDragGestureRecognizer(),
|
||||
),
|
||||
Factory<ScaleGestureRecognizer>(
|
||||
() => ScaleGestureRecognizer(),
|
||||
),
|
||||
Factory<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(),
|
||||
),
|
||||
},
|
||||
initialUrlRequest: URLRequest(
|
||||
url: WebUri(embedView.uri),
|
||||
),
|
||||
@@ -256,14 +269,14 @@ class EmbedViewRenderer extends HookConsumerWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.play_arrow,
|
||||
fill: 1,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant.withOpacity(
|
||||
0.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap to load content',
|
||||
'viewEmbedLoadHint'.tr(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant
|
||||
.withOpacity(0.6),
|
||||
|
@@ -31,6 +31,36 @@ import 'package:share_plus/share_plus.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:super_context_menu/super_context_menu.dart';
|
||||
|
||||
const kAvailableStickers = {
|
||||
'angry',
|
||||
'clap',
|
||||
'confuse',
|
||||
'pray',
|
||||
'thumb_up',
|
||||
'party',
|
||||
};
|
||||
|
||||
bool _getReactionImageAvailable(String symbol) {
|
||||
return kAvailableStickers.contains(symbol);
|
||||
}
|
||||
|
||||
Widget _buildReactionIcon(String symbol, double size, {double iconSize = 24}) {
|
||||
if (_getReactionImageAvailable(symbol)) {
|
||||
return Image.asset(
|
||||
'assets/images/stickers/$symbol.png',
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.bottomCenter,
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
kReactionTemplates[symbol]?.icon ?? '',
|
||||
style: TextStyle(fontSize: iconSize),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PostActionableItem extends HookConsumerWidget {
|
||||
final SnPost item;
|
||||
final EdgeInsets? padding;
|
||||
@@ -490,57 +520,66 @@ class PostItem extends HookConsumerWidget {
|
||||
trailing:
|
||||
isCompact
|
||||
? null
|
||||
: IconButton(
|
||||
icon:
|
||||
mostReaction == null
|
||||
? const Icon(Symbols.add_reaction)
|
||||
: Badge(
|
||||
label: Center(
|
||||
child: Text(
|
||||
'x${item.reactionsCount[mostReaction]}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
textAlign: TextAlign.center,
|
||||
: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
icon:
|
||||
mostReaction == null
|
||||
? const Icon(Symbols.add_reaction)
|
||||
: Badge(
|
||||
label: Center(
|
||||
child: Text(
|
||||
'x${item.reactionsCount[mostReaction]}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
offset: const Offset(4, 20),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.75),
|
||||
textColor:
|
||||
Theme.of(context).colorScheme.onPrimary,
|
||||
child: _buildReactionIcon(
|
||||
mostReaction,
|
||||
32,
|
||||
).padding(
|
||||
bottom:
|
||||
_getReactionImageAvailable(mostReaction)
|
||||
? 2
|
||||
: 0,
|
||||
),
|
||||
),
|
||||
offset: const Offset(4, 20),
|
||||
backgroundColor: Theme.of(
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
(item.reactionsMade[mostReaction] ?? false)
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.75),
|
||||
textColor:
|
||||
Theme.of(context).colorScheme.onPrimary,
|
||||
child: Text(
|
||||
kReactionTemplates[mostReaction]?.icon ?? '',
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
),
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
(item.reactionsMade[mostReaction] ?? false)
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.5)
|
||||
: null,
|
||||
).colorScheme.primary.withOpacity(0.5)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (BuildContext context) {
|
||||
return _PostReactionSheet(
|
||||
reactionsCount: item.reactionsCount,
|
||||
reactionsMade: item.reactionsMade,
|
||||
onReact: (symbol, attitude) {
|
||||
reactPost(symbol, attitude);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -3,
|
||||
vertical: -3,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder: (BuildContext context) {
|
||||
return _PostReactionSheet(
|
||||
reactionsCount: item.reactionsCount,
|
||||
reactionsMade: item.reactionsMade,
|
||||
onReact: (symbol, attitude) {
|
||||
reactPost(symbol, attitude);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -3,
|
||||
vertical: -3,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -611,7 +650,7 @@ class PostReactionList extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 28,
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: padding ?? EdgeInsets.zero,
|
||||
@@ -649,7 +688,7 @@ class PostReactionList extends HookConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ActionChip(
|
||||
avatar: Text(kReactionTemplates[symbol]?.icon ?? '?'),
|
||||
avatar: _buildReactionIcon(symbol, 24),
|
||||
label: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
@@ -786,37 +825,96 @@ class _PostReactionSheet extends StatelessWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final symbol = allReactions[index];
|
||||
final count = reactionsCount[symbol] ?? 0;
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
color:
|
||||
(reactionsMade[symbol] ?? false)
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerLowest,
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
onTap: () {
|
||||
onReact(symbol, attitude);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
kReactionTemplates[symbol]?.icon ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
).fontSize(24),
|
||||
Text(
|
||||
ReactInfo.getTranslationKey(symbol),
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
if (count > 0)
|
||||
Text(
|
||||
'x$count',
|
||||
textAlign: TextAlign.center,
|
||||
).bold().padding(bottom: 4)
|
||||
else
|
||||
const Gap(20),
|
||||
],
|
||||
final hasImage = _getReactionImageAvailable(symbol);
|
||||
return Badge(
|
||||
label: Text('x$count'),
|
||||
isLabelVisible: count > 0,
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
offset: Offset(0, 0),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLowest,
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
onTap: () {
|
||||
onReact(symbol, attitude);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Container(
|
||||
decoration:
|
||||
hasImage
|
||||
? BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: DecorationImage(
|
||||
image: AssetImage(
|
||||
'assets/images/stickers/$symbol.png',
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
colorFilter:
|
||||
(reactionsMade[symbol] ?? false)
|
||||
? ColorFilter.mode(
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primaryContainer
|
||||
.withOpacity(0.7),
|
||||
BlendMode.srcATop,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (hasImage)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withOpacity(0.7),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: [0.0, 0.3],
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisAlignment:
|
||||
hasImage
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!hasImage) _buildReactionIcon(symbol, 36),
|
||||
Text(
|
||||
ReactInfo.getTranslationKey(symbol),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: hasImage ? Colors.white : null,
|
||||
shadows:
|
||||
hasImage
|
||||
? [
|
||||
const Shadow(
|
||||
blurRadius: 4,
|
||||
offset: Offset(0.5, 0.5),
|
||||
color: Colors.black,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
).tr(),
|
||||
if (hasImage) const Gap(4),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 3.2.0+132
|
||||
version: 3.2.0+133
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.2
|
||||
@@ -189,6 +189,7 @@ flutter:
|
||||
- assets/i18n/
|
||||
- assets/images/
|
||||
- assets/images/oidc/
|
||||
- assets/images/stickers/
|
||||
- assets/icons/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
|
Reference in New Issue
Block a user