Compare commits

...

20 Commits

Author SHA1 Message Date
fa73a28324 🚀 Launch 2.0.0+4 (canary preview) 2024-11-15 00:55:49 +08:00
d945b103ca Websocket connection status indicator 2024-11-15 00:52:31 +08:00
8bc0da5188 Basic websocket connection 2024-11-15 00:24:46 +08:00
2e68d227a0 ⬆️ Upgrade deps 2024-11-14 23:25:02 +08:00
b8245b00b6 💄 Post item maxWidth 2024-11-14 22:49:17 +08:00
462e818078 💄 Optimize attachment list 2024-11-14 22:42:06 +08:00
e4582b7d25 ♻️ Optimized responsive navigation 2024-11-14 22:21:13 +08:00
00eef6e45a 🐛 Fix app drawer show on mobile 2024-11-14 13:02:42 +08:00
9498d428cd 🐛 Use only lang code on localization to fix not found bug 2024-11-14 00:40:58 +08:00
654a71e852 🚀 Launch 2.0.0+2 2024-11-14 00:30:46 +08:00
455ffcac19 Expanded drawer nav 2024-11-14 00:20:59 +08:00
9c8dad0176 Drawer navigation 2024-11-14 00:08:09 +08:00
2c6b1feca6 🐛 Fix login didn't request factor code correctly 2024-11-13 22:39:51 +08:00
af044a86bc Quoted (repost) post 2024-11-13 22:05:40 +08:00
4884d04a51 Sensitive content blur 2024-11-13 21:36:28 +08:00
b9ad6d4fd0 🐛 Fix attachment loading 2024-11-13 00:25:02 +08:00
468d1377af 🐛 Bug fixes and optimize image display 2024-11-13 00:13:27 +08:00
9851093a1e 🌐 Fix weird localization 2024-11-12 22:58:08 +08:00
5368f8ebb0 Post reaction 2024-11-12 20:47:40 +08:00
e5239a6ca0 Mini editor 2024-11-11 22:43:09 +08:00
57 changed files with 2321 additions and 670 deletions

13
.roadsignrc Normal file
View File

@@ -0,0 +1,13 @@
{
"sync": {
"region": "solian-next",
"configPath": "roadsign.toml"
},
"deployments": [
{
"region": "solian-next",
"site": "solian-next-web",
"path": "build/web"
}
]
}

View File

@@ -9,6 +9,13 @@
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
errors:
invalid_annotation_target: ignore # Due to freezed + json_serializable issue, ref https://github.com/rrousselGit/freezed/issues/488#issuecomment-894358980
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`

View File

@@ -15,6 +15,8 @@
"screenAccountPublisherEdit": "Edit Publisher",
"screenAccountProfileEdit": "Edit Profile",
"screenSettings": "Settings",
"screenAlbum": "Album",
"screenChat": "Chat",
"dialogOkay": "Okay",
"dialogCancel": "Cancel",
"dialogConfirm": "Confirm",
@@ -26,8 +28,8 @@
"errorRequestNotFound": "The resource that you looking for is not found.",
"errorRequestConnection": "Network connection error, please check your network or the service status.",
"errorRequestUnknown": "Unknown request error, maybe you want to take screenshot and report it to us.",
"prev": "Next",
"next": "Previous",
"prev": "Previous",
"next": "Next",
"edit": "Edit",
"apply": "Apply",
"create": "Create",
@@ -86,12 +88,21 @@
"fieldPostTitle": "Title",
"fieldPostDescription": "Description",
"postPublish": "Publish",
"postPosted": "Post has been posted.",
"postPublishedAt": "Published At",
"postPublishedUntil": "Published Until",
"postEditingNotice": "You're about to editing a post that posted {}.",
"postReplyingNotice": "You're about to reply to a post that posted {}.",
"postRepostingNotice": "You're about to repost a post that posted {}.",
"postReact": "React",
"postReactions": "Reactions of Post",
"postReactionPoints": {
"zero": "{} pt",
"one": "{} pt",
"other": "{} pts"
},
"postReactCompleted": "Reaction has been added.",
"postReactUncompleted": "Reaction has been removed.",
"postComments": {
"zero": "Comment",
"one": "{} comment",
@@ -116,5 +127,11 @@
"settingsNetworkServerResetDescription": "Reset to the official server address of Solar Network.",
"settingsNetworkServerPreset": "Present HyperNet Server",
"settingsNetworkServerPresetDescription": "You can choose one of our preset HyperNet server addresses from the list on the right.",
"settingsNetworkServerSaved": "Server address saved."
"settingsNetworkServerSaved": "Server address saved.",
"sensitiveContent": "Sensitive Content",
"sensitiveContentCollapsed": "Sensitive content has been collapsed.",
"sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.",
"sensitiveContentReveal": "Reveal",
"serverConnecting": "Connecting to server...",
"serverDisconnected": "Lost connection from server"
}

View File

@@ -15,6 +15,8 @@
"screenAccountPublisherEdit": "编辑发布者",
"screenAccountProfileEdit": "编辑资料",
"screenSettings": "设置",
"screenAlbum": "相册",
"screenChat": "聊天",
"dialogOkay": "好的",
"dialogCancel": "取消",
"dialogConfirm": "确认",
@@ -92,6 +94,15 @@
"postReplyingNotice": "你正在回复由 {} 发布的帖子。",
"postRepostingNotice": "你正在转发由 {} 发布的帖子。",
"postReact": "反应",
"postPosted": "帖子已经发表。",
"postReactions": "帖子的反应",
"postReactionPoints": {
"zero": "{} 点",
"one": "{} 点",
"other": "{} 点"
},
"postReactCompleted": "反应已被添加。",
"postReactUncompleted": "反应已被移除。",
"postComments": {
"zero": "评论",
"one": "{} 条评论",
@@ -116,5 +127,11 @@
"settingsNetworkServerResetDescription": "重设为 Solar Network 的服务器地址。",
"settingsNetworkServerPreset": "预设的 HyperNet 服务器",
"settingsNetworkServerPresetDescription": "你可以在旁边的列表中选择我们提供的预设 HyperNet 服务器地址。",
"settingsNetworkServerSaved": "服务器地址已保存。"
"settingsNetworkServerSaved": "服务器地址已保存。",
"sensitiveContent": "敏感内容",
"sensitiveContentCollapsed": "敏感内容已折叠。",
"sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
"sensitiveContentReveal": "显示内容",
"serverConnecting": "正在连接服务器…",
"serverDisconnected": "已与服务器断开连接"
}

View File

@@ -43,7 +43,7 @@ PODS:
- Flutter (1.0.0)
- flutter_native_splash (0.0.1):
- Flutter
- flutter_secure_storage (3.3.1):
- flutter_secure_storage (6.0.0):
- Flutter
- image_picker_ios (0.0.1):
- Flutter
@@ -119,7 +119,7 @@ SPEC CHECKSUMS:
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3

View File

@@ -161,7 +161,6 @@
64FBE78F9C282712818D6D95 /* Pods-RunnerTests.release.xcconfig */,
96081771773FA019A97CCC3F /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
@@ -474,6 +473,7 @@
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -657,6 +657,7 @@
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -680,6 +681,7 @@
DEVELOPMENT_TEAM = W7HPZ53V6B;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Solian;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Surface</string>
<string>Solian</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>surface</string>
<string>Solian</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>

View File

@@ -234,7 +234,7 @@ class PostWriteController extends ChangeNotifier {
}
}
void post(BuildContext context) async {
Future<void> post(BuildContext context) async {
if (isBusy || publisher == null) return;
final sn = context.read<SnNetworkProvider>();
@@ -294,8 +294,9 @@ class PostWriteController extends ChangeNotifier {
data: {
'publisher': publisher!.id,
'content': contentController.text,
'title': titleController.text,
'description': descriptionController.text,
if (titleController.text.isNotEmpty) 'title': titleController.text,
if (descriptionController.text.isNotEmpty)
'description': descriptionController.text,
'attachments': attachments
.where((e) => e.attachment != null)
.map((e) => e.attachment!.rid)
@@ -322,8 +323,6 @@ class PostWriteController extends ChangeNotifier {
method: editingPost != null ? 'PUT' : 'POST',
),
);
if (!context.mounted) return;
Navigator.pop(context, true);
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
@@ -368,6 +367,20 @@ class PostWriteController extends ChangeNotifier {
notifyListeners();
}
void reset() {
publishedAt = null;
publishedUntil = null;
titleController.clear();
descriptionController.clear();
contentController.clear();
attachments.clear();
editingPost = null;
replyingPost = null;
repostingPost = null;
mode = kTitleMap.keys.first;
notifyListeners();
}
@override
void dispose() {
contentController.dispose();

View File

@@ -6,10 +6,12 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:surface/providers/navigation.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/theme.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
import 'package:surface/router.dart';
void main() async {
@@ -34,13 +36,19 @@ class SolianApp extends StatelessWidget {
supportedLocales: [Locale('en', 'US'), Locale('zh', 'CN')],
fallbackLocale: Locale('en', 'US'),
useFallbackTranslations: true,
useOnlyLangCode: true,
assetLoader: JsonAssetLoader(),
child: MultiProvider(
providers: [
// Display layer
ChangeNotifierProvider(create: (_) => ThemeProvider()),
ChangeNotifierProvider(create: (ctx) => NavigationProvider()),
// Data layer
Provider(create: (_) => SnNetworkProvider()),
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
ChangeNotifierProvider(create: (_) => ThemeProvider()),
ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
],
child: AppMainContent(),
),
@@ -59,7 +67,8 @@ class AppMainContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
context.read<UserProvider>();
context.read<NavigationProvider>();
context.read<WebSocketProvider>();
final th = context.watch<ThemeProvider>();

View File

@@ -0,0 +1,112 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppNavDestination {
final String label;
final String screen;
final Widget icon;
final bool isPinned;
const AppNavDestination({
required this.label,
required this.screen,
required this.icon,
this.isPinned = false,
});
}
class NavigationProvider extends ChangeNotifier {
int? _currentIndex;
int? get currentIndex => _currentIndex;
static const List<AppNavDestination> kAllDestination = [
AppNavDestination(
icon: Icon(Symbols.home, weight: 400, opticalSize: 20),
screen: 'home',
label: 'screenHome',
),
AppNavDestination(
icon: Icon(Symbols.explore, weight: 400, opticalSize: 20),
screen: 'explore',
label: 'screenExplore',
),
AppNavDestination(
icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
screen: 'account',
label: 'screenAccount',
),
AppNavDestination(
icon: Icon(Symbols.album, weight: 400, opticalSize: 20),
screen: 'album',
label: 'screenAlbum',
),
AppNavDestination(
icon: Icon(Symbols.chat, weight: 400, opticalSize: 20),
screen: 'chat',
label: 'screenChat',
),
];
static const List<String> kDefaultPinnedDestination = [
'home',
'explore',
'account'
];
List<AppNavDestination> destinations = [];
int get pinnedDestinationCount =>
destinations.where((ele) => ele.isPinned).length;
NavigationProvider() {
buildDestinations(kDefaultPinnedDestination);
SharedPreferences.getInstance().then((prefs) {
final pinned = prefs.getStringList("app_pinned_navigation");
if (pinned != null) buildDestinations(pinned);
});
}
void buildDestinations(List<String> pinned) {
destinations = kAllDestination
.map(
(ele) => AppNavDestination(
label: ele.label,
screen: ele.screen,
icon: ele.icon,
isPinned: pinned.contains(ele.screen),
),
)
.toList();
notifyListeners();
}
int getIndexInRange(int min, int max) {
return math.max(min, math.min(_currentIndex ?? 0, max));
}
bool isIndexInRange(int min, int max) {
return _currentIndex != null &&
_currentIndex! >= min &&
_currentIndex! < max;
}
void autoDetectIndex(GoRouter? state) {
if (state == null) return;
final idx = destinations.indexWhere(
(ele) =>
ele.screen ==
state.routerDelegate.currentConfiguration.last.route.name,
);
_currentIndex = idx == -1 ? null : idx;
notifyListeners();
}
void setIndex(int idx) {
_currentIndex = idx;
notifyListeners();
}
}

View File

@@ -44,12 +44,19 @@ class SnAttachmentProvider {
'take': pendingFetch.length,
'id': pendingFetch.join(','),
});
final out = resp.data['data'].map((e) => SnAttachment.fromJson(e)).toList();
final out = resp.data['data']
.where((e) => e['id'] != 0)
.map((e) => SnAttachment.fromJson(e))
.toList();
for (var i = 0; i < out.length; i++) {
_cache[pendingFetch[i]] = out[i];
for (final item in out) {
_cache[item.rid] = item;
}
return rids.map((rid) => _cache[rid]!).toList();
return rids
.where((rid) => _cache.containsKey(rid))
.map((rid) => _cache[rid]!)
.toList();
}
static Map<String, String> mimetypeOverrides = {

View File

@@ -44,48 +44,11 @@ class SnNetworkProvider {
RequestOptions options,
RequestInterceptorHandler handler,
) async {
try {
var atk = await _storage.read(key: kAtkStoreKey);
if (atk != null) {
final atkParts = atk.split('.');
if (atkParts.length != 3) {
throw Exception('invalid format of access token');
}
var rawPayload =
atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
switch (rawPayload.length % 4) {
case 0:
break;
case 2:
rawPayload += '==';
break;
case 3:
rawPayload += '=';
break;
default:
throw Exception('illegal format of access token payload');
}
final b64 = utf8.fuse(base64Url);
final payload = b64.decode(rawPayload);
final exp = jsonDecode(payload)['exp'];
if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
log('Access token need refresh, doing it at ${DateTime.now()}');
atk = await refreshToken();
}
if (atk != null) {
options.headers['Authorization'] = 'Bearer $atk';
} else {
log('Access token refresh failed...');
}
}
} catch (err) {
log('Failed to authenticate user: $err');
} finally {
handler.next(options);
final atk = await getFreshAtk();
if (atk != null) {
options.headers['Authorization'] = 'Bearer $atk';
}
return handler.next(options);
},
),
);
@@ -99,8 +62,52 @@ class SnNetworkProvider {
});
}
Future<String?> getFreshAtk() async {
try {
var atk = await _storage.read(key: kAtkStoreKey);
if (atk != null) {
final atkParts = atk.split('.');
if (atkParts.length != 3) {
throw Exception('invalid format of access token');
}
var rawPayload = atkParts[1].replaceAll('-', '+').replaceAll('_', '/');
switch (rawPayload.length % 4) {
case 0:
break;
case 2:
rawPayload += '==';
break;
case 3:
rawPayload += '=';
break;
default:
throw Exception('illegal format of access token payload');
}
final b64 = utf8.fuse(base64Url);
final payload = b64.decode(rawPayload);
final exp = jsonDecode(payload)['exp'];
if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
log('Access token need refresh, doing it at ${DateTime.now()}');
atk = await refreshToken();
}
if (atk != null) {
return atk;
} else {
log('Access token refresh failed...');
}
}
} catch (err) {
log('Failed to authenticate user: $err');
}
return null;
}
String getAttachmentUrl(String ky) {
if (ky.startsWith("http://")) return ky;
if (ky.startsWith("http")) return ky;
return '${client.options.baseUrl}/cgi/uc/attachments/$ky';
}

View File

@@ -13,6 +13,8 @@ class UserProvider extends ChangeNotifier {
late final SnNetworkProvider _sn;
late final FlutterSecureStorage _storage = FlutterSecureStorage();
Future<String?> get atk => _storage.read(key: kAtkStoreKey);
UserProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();

View File

@@ -0,0 +1,110 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/websocket.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class WebSocketProvider extends ChangeNotifier {
bool isBusy = false;
bool isConnected = false;
WebSocketChannel? conn;
late final SnNetworkProvider _sn;
late final UserProvider _ua;
StreamController<WebSocketPackage> stream = StreamController.broadcast();
WebSocketProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>();
_ua = context.read<UserProvider>();
// Wait for the userinfo provide initialize authorization status
Future.delayed(const Duration(milliseconds: 250), () async {
if (_ua.isAuthorized) {
log('[WebSocket] Connecting to the server...');
await connect();
} else {
log('[WebSocket] Unable connect to the server, unauthorized.');
}
});
}
Future<void> connect({noRetry = false}) async {
if (!_ua.isAuthorized) return;
if (isConnected) {
disconnect();
}
final atk = await _sn.getFreshAtk();
final uri = Uri.parse(
'${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
);
isBusy = true;
notifyListeners();
try {
conn = WebSocketChannel.connect(uri);
await conn!.ready;
log('[WebSocket] Connected to server!');
isConnected = true;
} catch (err) {
if (err is WebSocketChannelException) {
log('Failed to connect to websocket: ${(err.inner as dynamic).message}');
} else {
log('Failed to connect to websocket: $err');
}
if (!noRetry) {
log('Retry connecting to websocket in 3 seconds...');
return Future.delayed(
const Duration(seconds: 3),
() => connect(noRetry: true),
);
}
} finally {
isBusy = false;
notifyListeners();
}
}
void disconnect() {
if (conn != null) {
conn!.sink.close();
}
isConnected = false;
notifyListeners();
}
void listen() {
conn?.stream.listen(
(event) {
final packet = WebSocketPackage.fromJson(jsonDecode(event));
log('Websocket incoming message: ${packet.method} ${packet.message}');
stream.sink.add(packet);
// TODO handle notification
// if (packet.method == 'notifications.new') {
// final NotificationProvider nty = Get.find();
// nty.notifications.add(Notification.fromJson(packet.payload!));
// nty.notificationUnread.value++;
// }
},
onDone: () {
isConnected = false;
notifyListeners();
Future.delayed(const Duration(seconds: 1), () => connect());
},
onError: (err) {
isConnected = false;
notifyListeners();
Future.delayed(const Duration(seconds: 11), () => connect());
},
);
}
}

View File

@@ -4,8 +4,10 @@ import 'package:surface/screens/account/profile_edit.dart';
import 'package:surface/screens/account/publishers/publisher_edit.dart';
import 'package:surface/screens/account/publishers/publisher_new.dart';
import 'package:surface/screens/account/publishers/publishers.dart';
import 'package:surface/screens/album.dart';
import 'package:surface/screens/auth/login.dart';
import 'package:surface/screens/auth/register.dart';
import 'package:surface/screens/chat.dart';
import 'package:surface/screens/explore.dart';
import 'package:surface/screens/home.dart';
import 'package:surface/screens/post/post_detail.dart';
@@ -14,114 +16,124 @@ import 'package:surface/screens/settings.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
final _appRoutes = [
ShellRoute(
builder: (context, state, child) => AppScaffold(
body: child,
showBottomNavigation: true,
showAppBar: false,
),
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/posts',
name: 'explore',
builder: (context, state) => const ExploreScreen(),
),
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) => const AccountScreen(),
),
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) => const ChatScreen(),
),
GoRoute(
path: '/album',
name: 'album',
builder: (context, state) => const AlbumScreen(),
),
],
),
ShellRoute(
builder: (context, state, child) => child,
routes: [
GoRoute(
path: '/post/write/:mode',
name: 'postEditor',
builder: (context, state) => PostEditorScreen(
mode: state.pathParameters['mode']!,
postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '',
),
postReplyId: int.tryParse(
state.uri.queryParameters['replying'] ?? '',
),
postRepostId: int.tryParse(
state.uri.queryParameters['reposting'] ?? '',
),
),
),
GoRoute(
path: '/post/:slug',
name: 'postDetail',
builder: (context, state) => PostDetailScreen(
slug: state.pathParameters['slug']!,
preload: state.extra as SnPost?,
),
)
],
),
ShellRoute(
builder: (context, state, child) => AppScaffold(body: child),
routes: [
GoRoute(
path: '/auth/login',
name: 'authLogin',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
name: 'authRegister',
builder: (context, state) => const RegisterScreen(),
),
GoRoute(
path: '/account/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => const ProfileEditScreen(),
),
GoRoute(
path: '/account/publishers',
name: 'accountPublishers',
builder: (context, state) => const PublisherScreen(),
),
GoRoute(
path: '/account/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => const AccountPublisherNewScreen(),
),
GoRoute(
path: '/account/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
),
],
),
ShellRoute(
builder: (context, state, child) => AppScaffold(body: child),
routes: [
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => const SettingsScreen(),
),
],
),
];
final appRouter = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) => AppScaffold(
body: child,
showBottomNavigation: true,
),
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/posts',
name: 'explore',
builder: (context, state) => const ExploreScreen(),
),
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) => const AccountScreen(),
),
],
),
ShellRoute(
builder: (context, state, child) => AppScaffold(
body: child,
),
routes: [
GoRoute(
path: '/post/write/:mode',
name: 'postEditor',
builder: (context, state) => PostEditorScreen(
mode: state.pathParameters['mode']!,
postEditId: int.tryParse(
state.uri.queryParameters['editing'] ?? '',
),
postReplyId: int.tryParse(
state.uri.queryParameters['replying'] ?? '',
),
postRepostId: int.tryParse(
state.uri.queryParameters['reposting'] ?? '',
),
),
),
GoRoute(
path: '/post/:slug',
name: 'postDetail',
builder: (context, state) => PostDetailScreen(
slug: state.pathParameters['slug']!,
preload: state.extra as SnPost?,
),
)
],
),
ShellRoute(
builder: (context, state, child) => AppScaffold(
body: child,
autoImplyAppBar: true,
),
routes: [
GoRoute(
path: '/auth/login',
name: 'authLogin',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
name: 'authRegister',
builder: (context, state) => const RegisterScreen(),
),
GoRoute(
path: '/account/profile/edit',
name: 'accountProfileEdit',
builder: (context, state) => const ProfileEditScreen(),
),
GoRoute(
path: '/account/publishers',
name: 'accountPublishers',
builder: (context, state) => const PublisherScreen(),
),
GoRoute(
path: '/account/publishers/new',
name: 'accountPublisherNew',
builder: (context, state) => const AccountPublisherNewScreen(),
),
GoRoute(
path: '/account/publishers/edit/:name',
name: 'accountPublisherEdit',
builder: (context, state) => AccountPublisherEditScreen(
name: state.pathParameters['name']!,
),
),
],
),
ShellRoute(
builder: (context, state, child) => AppScaffold(
body: child,
autoImplyAppBar: true,
),
routes: [
GoRoute(
path: '/settings',
name: 'settings',
builder: (context, state) => const SettingsScreen(),
),
],
builder: (context, state, child) => AppRootScaffold(body: child),
routes: _appRoutes,
),
],
);

View File

@@ -8,7 +8,6 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@@ -17,7 +16,7 @@ class AccountScreen extends StatelessWidget {
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
return AppScaffold(
return Scaffold(
appBar: AppBar(
title: Text("screenAccount").tr(),
actions: [

View File

@@ -232,7 +232,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
color:
Theme.of(context).colorScheme.surfaceContainerHigh,
child: _banner != null
? UniversalImage(
? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!),
fit: BoxFit.cover,
)

View File

@@ -18,7 +18,6 @@ import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/universal_image.dart';
class AccountPublisherEditScreen extends StatefulWidget {
@@ -189,7 +188,7 @@ class _AccountPublisherEditScreenState
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
return AppScaffold(
return Scaffold(
body: SingleChildScrollView(
child: Column(
children: [
@@ -210,7 +209,7 @@ class _AccountPublisherEditScreenState
.colorScheme
.surfaceContainerHigh,
child: _banner != null
? UniversalImage(
? AutoResizeUniversalImage(
sn.getAttachmentUrl(_banner!),
fit: BoxFit.cover,
)

View File

@@ -8,7 +8,6 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountPublisherNewScreen extends StatefulWidget {
const AccountPublisherNewScreen({super.key});
@@ -23,7 +22,7 @@ class _AccountPublisherNewScreenState extends State<AccountPublisherNewScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
return Scaffold(
body: SingleChildScrollView(
child: Column(
children: [

View File

@@ -1,5 +1,4 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
@@ -11,7 +10,6 @@ import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class PublisherScreen extends StatefulWidget {
const PublisherScreen({super.key});
@@ -55,7 +53,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
return Scaffold(
body: Column(
children: [
ListTile(

10
lib/screens/album.dart Normal file
View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class AlbumScreen extends StatelessWidget {
const AlbumScreen({super.key});
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}

View File

@@ -73,7 +73,7 @@ class _LoginScreenState extends State<LoginScreen> {
onTicket: (p0) => setState(() {
_currentTicket = p0;
}),
onNext: (p0) => setState(() {
onNext: () => setState(() {
_period = 1;
}),
),
@@ -271,13 +271,14 @@ class _LoginPickerScreenState extends State<_LoginPickerScreen> {
try {
// Request one-time-password code
sn.client.post('/cgi/id/auth/factors/$_factorPicked');
await sn.client.post('/cgi/id/auth/factors/$_factorPicked');
widget.onPickFactor(
widget.factors!.where((x) => x.id == _factorPicked).first,
);
widget.onNext();
} catch (err) {
context.showErrorDialog(err);
// ignore: use_build_context_synchronously
if (context.mounted) context.showErrorDialog(err);
return;
} finally {
setState(() => _isBusy = false);

10
lib/screens/chat.dart Normal file
View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class ChatScreen extends StatelessWidget {
const ChatScreen({super.key});
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}

View File

@@ -5,10 +5,10 @@ import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
@@ -74,7 +74,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
return Scaffold(
floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: ExpandableFab(
key: _fabKey,
@@ -173,7 +173,10 @@ class _ExploreScreenState extends State<ExploreScreen> {
onFetchData: _fetchPosts,
itemBuilder: (context, idx) {
return GestureDetector(
child: PostItem(data: _posts[idx]),
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: PostItem(data: _posts[idx]),
).center(),
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',

View File

@@ -1,7 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:flutter/material.dart';
class HomeScreen extends StatefulWidget {
@@ -14,7 +13,7 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
return Scaffold(
appBar: AppBar(
title: Text("screenHome").tr(),
),

View File

@@ -4,16 +4,18 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_comment_list.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_mini_editor.dart';
class PostDetailScreen extends StatefulWidget {
final String slug;
@@ -45,13 +47,11 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
resp.data['body']['attachments']?.cast<String>() ?? [],
);
if (!mounted) return;
setState(() {
_data = SnPost.fromJson(resp.data).copyWith(
preload: SnPostPreload(
attachments: attachments,
),
);
});
_data = SnPost.fromJson(resp.data).copyWith(
preload: SnPostPreload(
attachments: attachments,
),
);
} catch (err) {
context.showErrorDialog(err);
} finally {
@@ -68,9 +68,14 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
_fetchPost();
}
final GlobalKey<PostCommentSliverListState> _childListKey = GlobalKey();
@override
Widget build(BuildContext context) {
return AppScaffold(
final ua = context.watch<UserProvider>();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Scaffold(
appBar: AppBar(
leading: BackButton(
onPressed: () {
@@ -81,13 +86,19 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
},
),
flexibleSpace: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_data?.body['title'] ?? 'postNoun'.tr())
.textStyle(Theme.of(context).textTheme.titleLarge!)
.textColor(Colors.white),
Text('postDetail')
.tr()
.textColor(Colors.white.withAlpha((255 * 0.9).round())),
if (_data?.body['title'] != null)
Text(_data?.body['title'] ?? 'postNoun'.tr())
.textStyle(Theme.of(context).textTheme.titleLarge!)
.textColor(Colors.white),
if (_data?.body['title'] != null)
Text('postDetail'.tr())
.textColor(Colors.white.withAlpha((255 * 0.9).round()))
else
Text('postDetail'.tr())
.textStyle(Theme.of(context).textTheme.titleLarge!)
.textColor(Colors.white),
],
).padding(top: math.max(MediaQuery.of(context).padding.top, 8)),
),
@@ -98,17 +109,61 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
),
if (_data != null)
SliverToBoxAdapter(
child: PostItem(data: _data!),
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: PostItem(
data: _data!,
showComments: false,
),
).center(),
),
const SliverToBoxAdapter(child: Divider(height: 1)),
if (_data != null)
SliverToBoxAdapter(
child: Text('postCommentsDetailed')
.plural(_data!.metric.replyCount)
.textStyle(Theme.of(context).textTheme.titleLarge!)
.padding(horizontal: 16, top: 12, bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.comment, size: 24),
const Gap(16),
Text('postCommentsDetailed')
.plural(_data!.metric.replyCount)
.textStyle(Theme.of(context).textTheme.titleLarge!),
],
).padding(horizontal: 20, vertical: 12),
),
if (_data != null && ua.isAuthorized)
SliverToBoxAdapter(
child: Container(
height: 240,
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
),
),
child: PostMiniEditor(
postReplyId: _data!.id,
onPost: () {
_childListKey.currentState!.refresh();
setState(() {
_data = _data!.copyWith(
metric: _data!.metric.copyWith(
replyCount: _data!.metric.replyCount + 1,
),
);
});
},
),
),
),
if (_data != null)
PostCommentSliverList(
key: _childListKey,
parentPostId: _data!.id,
maxWidth: 640,
),
if (_data != null) PostCommentSliverList(parentPostId: _data!.id),
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)),
],
),

View File

@@ -15,7 +15,6 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_media_pending_list.dart';
import 'package:surface/widgets/post/post_meta_editor.dart';
@@ -111,7 +110,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
return ListenableBuilder(
listenable: _writeController,
builder: (context, _) {
return AppScaffold(
return Scaffold(
appBar: AppBar(
leading: BackButton(
onPressed: () {
@@ -328,7 +327,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
)
),
]
.expandIndexed(
(idx, ele) => [
@@ -390,7 +389,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
onPressed: (_writeController.isBusy ||
_writeController.publisher == null)
? null
: () => _writeController.post(context),
: () {
_writeController.post(context).then((_) {
if (!context.mounted) return;
Navigator.pop(context, true);
});
},
icon: const Icon(Symbols.send),
label: Text('postPublish').tr(),
),

View File

@@ -15,7 +15,6 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/theme.dart';
import 'package:surface/theme.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@@ -58,7 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Widget build(BuildContext context) {
final sn = context.read<SnNetworkProvider>();
return AppScaffold(
return Scaffold(
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -20,14 +20,13 @@ class SnPost with _$SnPost {
required String? aliasPrefix,
required List<dynamic> tags,
required List<dynamic> categories,
required dynamic reactions,
required dynamic replies,
required dynamic replyId,
required dynamic repostId,
required dynamic replyTo,
required dynamic repostTo,
required dynamic visibleUsersList,
required dynamic invisibleUsersList,
required List<SnPost>? replies,
required int? replyId,
required int? repostId,
required SnPost? replyTo,
required SnPost? repostTo,
required List<int>? visibleUsersList,
required List<int>? invisibleUsersList,
required int visibility,
required DateTime? editedAt,
required DateTime? pinnedAt,
@@ -37,8 +36,6 @@ class SnPost with _$SnPost {
required DateTime? publishedUntil,
required int totalUpvote,
required int totalDownvote,
required int? realmId,
required dynamic realm,
required int publisherId,
required SnPublisher publisher,
required SnMetric metric,
@@ -81,6 +78,7 @@ class SnMetric with _$SnMetric {
const factory SnMetric({
required int replyCount,
required int reactionCount,
@Default({}) Map<String, int> reactionList,
}) = _SnMetric;
factory SnMetric.fromJson(Map<String, Object?> json) =>

View File

@@ -31,14 +31,13 @@ mixin _$SnPost {
String? get aliasPrefix => throw _privateConstructorUsedError;
List<dynamic> get tags => throw _privateConstructorUsedError;
List<dynamic> get categories => throw _privateConstructorUsedError;
dynamic get reactions => throw _privateConstructorUsedError;
dynamic get replies => throw _privateConstructorUsedError;
dynamic get replyId => throw _privateConstructorUsedError;
dynamic get repostId => throw _privateConstructorUsedError;
dynamic get replyTo => throw _privateConstructorUsedError;
dynamic get repostTo => throw _privateConstructorUsedError;
dynamic get visibleUsersList => throw _privateConstructorUsedError;
dynamic get invisibleUsersList => throw _privateConstructorUsedError;
List<SnPost>? get replies => throw _privateConstructorUsedError;
int? get replyId => throw _privateConstructorUsedError;
int? get repostId => throw _privateConstructorUsedError;
SnPost? get replyTo => throw _privateConstructorUsedError;
SnPost? get repostTo => throw _privateConstructorUsedError;
List<int>? get visibleUsersList => throw _privateConstructorUsedError;
List<int>? get invisibleUsersList => throw _privateConstructorUsedError;
int get visibility => throw _privateConstructorUsedError;
DateTime? get editedAt => throw _privateConstructorUsedError;
DateTime? get pinnedAt => throw _privateConstructorUsedError;
@@ -48,8 +47,6 @@ mixin _$SnPost {
DateTime? get publishedUntil => throw _privateConstructorUsedError;
int get totalUpvote => throw _privateConstructorUsedError;
int get totalDownvote => throw _privateConstructorUsedError;
int? get realmId => throw _privateConstructorUsedError;
dynamic get realm => throw _privateConstructorUsedError;
int get publisherId => throw _privateConstructorUsedError;
SnPublisher get publisher => throw _privateConstructorUsedError;
SnMetric get metric => throw _privateConstructorUsedError;
@@ -81,14 +78,13 @@ abstract class $SnPostCopyWith<$Res> {
String? aliasPrefix,
List<dynamic> tags,
List<dynamic> categories,
dynamic reactions,
dynamic replies,
dynamic replyId,
dynamic repostId,
dynamic replyTo,
dynamic repostTo,
dynamic visibleUsersList,
dynamic invisibleUsersList,
List<SnPost>? replies,
int? replyId,
int? repostId,
SnPost? replyTo,
SnPost? repostTo,
List<int>? visibleUsersList,
List<int>? invisibleUsersList,
int visibility,
DateTime? editedAt,
DateTime? pinnedAt,
@@ -98,13 +94,13 @@ abstract class $SnPostCopyWith<$Res> {
DateTime? publishedUntil,
int totalUpvote,
int totalDownvote,
int? realmId,
dynamic realm,
int publisherId,
SnPublisher publisher,
SnMetric metric,
SnPostPreload? preload});
$SnPostCopyWith<$Res>? get replyTo;
$SnPostCopyWith<$Res>? get repostTo;
$SnPublisherCopyWith<$Res> get publisher;
$SnMetricCopyWith<$Res> get metric;
$SnPostPreloadCopyWith<$Res>? get preload;
@@ -136,7 +132,6 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
Object? aliasPrefix = freezed,
Object? tags = null,
Object? categories = null,
Object? reactions = freezed,
Object? replies = freezed,
Object? replyId = freezed,
Object? repostId = freezed,
@@ -153,8 +148,6 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
Object? publishedUntil = freezed,
Object? totalUpvote = null,
Object? totalDownvote = null,
Object? realmId = freezed,
Object? realm = freezed,
Object? publisherId = null,
Object? publisher = null,
Object? metric = null,
@@ -205,38 +198,34 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
? _value.categories
: categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>,
reactions: freezed == reactions
? _value.reactions
: reactions // ignore: cast_nullable_to_non_nullable
as dynamic,
replies: freezed == replies
? _value.replies
: replies // ignore: cast_nullable_to_non_nullable
as dynamic,
as List<SnPost>?,
replyId: freezed == replyId
? _value.replyId
: replyId // ignore: cast_nullable_to_non_nullable
as dynamic,
as int?,
repostId: freezed == repostId
? _value.repostId
: repostId // ignore: cast_nullable_to_non_nullable
as dynamic,
as int?,
replyTo: freezed == replyTo
? _value.replyTo
: replyTo // ignore: cast_nullable_to_non_nullable
as dynamic,
as SnPost?,
repostTo: freezed == repostTo
? _value.repostTo
: repostTo // ignore: cast_nullable_to_non_nullable
as dynamic,
as SnPost?,
visibleUsersList: freezed == visibleUsersList
? _value.visibleUsersList
: visibleUsersList // ignore: cast_nullable_to_non_nullable
as dynamic,
as List<int>?,
invisibleUsersList: freezed == invisibleUsersList
? _value.invisibleUsersList
: invisibleUsersList // ignore: cast_nullable_to_non_nullable
as dynamic,
as List<int>?,
visibility: null == visibility
? _value.visibility
: visibility // ignore: cast_nullable_to_non_nullable
@@ -273,14 +262,6 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
? _value.totalDownvote
: totalDownvote // ignore: cast_nullable_to_non_nullable
as int,
realmId: freezed == realmId
? _value.realmId
: realmId // ignore: cast_nullable_to_non_nullable
as int?,
realm: freezed == realm
? _value.realm
: realm // ignore: cast_nullable_to_non_nullable
as dynamic,
publisherId: null == publisherId
? _value.publisherId
: publisherId // ignore: cast_nullable_to_non_nullable
@@ -300,6 +281,34 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
) as $Val);
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPostCopyWith<$Res>? get replyTo {
if (_value.replyTo == null) {
return null;
}
return $SnPostCopyWith<$Res>(_value.replyTo!, (value) {
return _then(_value.copyWith(replyTo: value) as $Val);
});
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPostCopyWith<$Res>? get repostTo {
if (_value.repostTo == null) {
return null;
}
return $SnPostCopyWith<$Res>(_value.repostTo!, (value) {
return _then(_value.copyWith(repostTo: value) as $Val);
});
}
/// Create a copy of SnPost
/// with the given fields replaced by the non-null parameter values.
@override
@@ -354,14 +363,13 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
String? aliasPrefix,
List<dynamic> tags,
List<dynamic> categories,
dynamic reactions,
dynamic replies,
dynamic replyId,
dynamic repostId,
dynamic replyTo,
dynamic repostTo,
dynamic visibleUsersList,
dynamic invisibleUsersList,
List<SnPost>? replies,
int? replyId,
int? repostId,
SnPost? replyTo,
SnPost? repostTo,
List<int>? visibleUsersList,
List<int>? invisibleUsersList,
int visibility,
DateTime? editedAt,
DateTime? pinnedAt,
@@ -371,13 +379,15 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
DateTime? publishedUntil,
int totalUpvote,
int totalDownvote,
int? realmId,
dynamic realm,
int publisherId,
SnPublisher publisher,
SnMetric metric,
SnPostPreload? preload});
@override
$SnPostCopyWith<$Res>? get replyTo;
@override
$SnPostCopyWith<$Res>? get repostTo;
@override
$SnPublisherCopyWith<$Res> get publisher;
@override
@@ -410,7 +420,6 @@ class __$$SnPostImplCopyWithImpl<$Res>
Object? aliasPrefix = freezed,
Object? tags = null,
Object? categories = null,
Object? reactions = freezed,
Object? replies = freezed,
Object? replyId = freezed,
Object? repostId = freezed,
@@ -427,8 +436,6 @@ class __$$SnPostImplCopyWithImpl<$Res>
Object? publishedUntil = freezed,
Object? totalUpvote = null,
Object? totalDownvote = null,
Object? realmId = freezed,
Object? realm = freezed,
Object? publisherId = null,
Object? publisher = null,
Object? metric = null,
@@ -479,38 +486,34 @@ class __$$SnPostImplCopyWithImpl<$Res>
? _value._categories
: categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>,
reactions: freezed == reactions
? _value.reactions
: reactions // ignore: cast_nullable_to_non_nullable
as dynamic,
replies: freezed == replies
? _value.replies
? _value._replies
: replies // ignore: cast_nullable_to_non_nullable
as dynamic,
as List<SnPost>?,
replyId: freezed == replyId
? _value.replyId
: replyId // ignore: cast_nullable_to_non_nullable
as dynamic,
as int?,
repostId: freezed == repostId
? _value.repostId
: repostId // ignore: cast_nullable_to_non_nullable
as dynamic,
as int?,
replyTo: freezed == replyTo
? _value.replyTo
: replyTo // ignore: cast_nullable_to_non_nullable
as dynamic,
as SnPost?,
repostTo: freezed == repostTo
? _value.repostTo
: repostTo // ignore: cast_nullable_to_non_nullable
as dynamic,
as SnPost?,
visibleUsersList: freezed == visibleUsersList
? _value.visibleUsersList
? _value._visibleUsersList
: visibleUsersList // ignore: cast_nullable_to_non_nullable
as dynamic,
as List<int>?,
invisibleUsersList: freezed == invisibleUsersList
? _value.invisibleUsersList
? _value._invisibleUsersList
: invisibleUsersList // ignore: cast_nullable_to_non_nullable
as dynamic,
as List<int>?,
visibility: null == visibility
? _value.visibility
: visibility // ignore: cast_nullable_to_non_nullable
@@ -547,14 +550,6 @@ class __$$SnPostImplCopyWithImpl<$Res>
? _value.totalDownvote
: totalDownvote // ignore: cast_nullable_to_non_nullable
as int,
realmId: freezed == realmId
? _value.realmId
: realmId // ignore: cast_nullable_to_non_nullable
as int?,
realm: freezed == realm
? _value.realm
: realm // ignore: cast_nullable_to_non_nullable
as dynamic,
publisherId: null == publisherId
? _value.publisherId
: publisherId // ignore: cast_nullable_to_non_nullable
@@ -590,14 +585,13 @@ class _$SnPostImpl extends _SnPost {
required this.aliasPrefix,
required final List<dynamic> tags,
required final List<dynamic> categories,
required this.reactions,
required this.replies,
required final List<SnPost>? replies,
required this.replyId,
required this.repostId,
required this.replyTo,
required this.repostTo,
required this.visibleUsersList,
required this.invisibleUsersList,
required final List<int>? visibleUsersList,
required final List<int>? invisibleUsersList,
required this.visibility,
required this.editedAt,
required this.pinnedAt,
@@ -607,8 +601,6 @@ class _$SnPostImpl extends _SnPost {
required this.publishedUntil,
required this.totalUpvote,
required this.totalDownvote,
required this.realmId,
required this.realm,
required this.publisherId,
required this.publisher,
required this.metric,
@@ -616,6 +608,9 @@ class _$SnPostImpl extends _SnPost {
: _body = body,
_tags = tags,
_categories = categories,
_replies = replies,
_visibleUsersList = visibleUsersList,
_invisibleUsersList = invisibleUsersList,
super._();
factory _$SnPostImpl.fromJson(Map<String, dynamic> json) =>
@@ -661,22 +656,46 @@ class _$SnPostImpl extends _SnPost {
return EqualUnmodifiableListView(_categories);
}
final List<SnPost>? _replies;
@override
final dynamic reactions;
List<SnPost>? get replies {
final value = _replies;
if (value == null) return null;
if (_replies is EqualUnmodifiableListView) return _replies;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@override
final dynamic replies;
final int? replyId;
@override
final dynamic replyId;
final int? repostId;
@override
final dynamic repostId;
final SnPost? replyTo;
@override
final dynamic replyTo;
final SnPost? repostTo;
final List<int>? _visibleUsersList;
@override
final dynamic repostTo;
List<int>? get visibleUsersList {
final value = _visibleUsersList;
if (value == null) return null;
if (_visibleUsersList is EqualUnmodifiableListView)
return _visibleUsersList;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
final List<int>? _invisibleUsersList;
@override
final dynamic visibleUsersList;
@override
final dynamic invisibleUsersList;
List<int>? get invisibleUsersList {
final value = _invisibleUsersList;
if (value == null) return null;
if (_invisibleUsersList is EqualUnmodifiableListView)
return _invisibleUsersList;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(value);
}
@override
final int visibility;
@override
@@ -696,10 +715,6 @@ class _$SnPostImpl extends _SnPost {
@override
final int totalDownvote;
@override
final int? realmId;
@override
final dynamic realm;
@override
final int publisherId;
@override
final SnPublisher publisher;
@@ -710,7 +725,7 @@ class _$SnPostImpl extends _SnPost {
@override
String toString() {
return 'SnPost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, body: $body, language: $language, alias: $alias, aliasPrefix: $aliasPrefix, tags: $tags, categories: $categories, reactions: $reactions, replies: $replies, replyId: $replyId, repostId: $repostId, replyTo: $replyTo, repostTo: $repostTo, visibleUsersList: $visibleUsersList, invisibleUsersList: $invisibleUsersList, visibility: $visibility, editedAt: $editedAt, pinnedAt: $pinnedAt, lockedAt: $lockedAt, isDraft: $isDraft, publishedAt: $publishedAt, publishedUntil: $publishedUntil, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, realmId: $realmId, realm: $realm, publisherId: $publisherId, publisher: $publisher, metric: $metric, preload: $preload)';
return 'SnPost(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, type: $type, body: $body, language: $language, alias: $alias, aliasPrefix: $aliasPrefix, tags: $tags, categories: $categories, replies: $replies, replyId: $replyId, repostId: $repostId, replyTo: $replyTo, repostTo: $repostTo, visibleUsersList: $visibleUsersList, invisibleUsersList: $invisibleUsersList, visibility: $visibility, editedAt: $editedAt, pinnedAt: $pinnedAt, lockedAt: $lockedAt, isDraft: $isDraft, publishedAt: $publishedAt, publishedUntil: $publishedUntil, totalUpvote: $totalUpvote, totalDownvote: $totalDownvote, publisherId: $publisherId, publisher: $publisher, metric: $metric, preload: $preload)';
}
@override
@@ -735,16 +750,17 @@ class _$SnPostImpl extends _SnPost {
const DeepCollectionEquality().equals(other._tags, _tags) &&
const DeepCollectionEquality()
.equals(other._categories, _categories) &&
const DeepCollectionEquality().equals(other.reactions, reactions) &&
const DeepCollectionEquality().equals(other.replies, replies) &&
const DeepCollectionEquality().equals(other.replyId, replyId) &&
const DeepCollectionEquality().equals(other.repostId, repostId) &&
const DeepCollectionEquality().equals(other.replyTo, replyTo) &&
const DeepCollectionEquality().equals(other.repostTo, repostTo) &&
const DeepCollectionEquality().equals(other._replies, _replies) &&
(identical(other.replyId, replyId) || other.replyId == replyId) &&
(identical(other.repostId, repostId) ||
other.repostId == repostId) &&
(identical(other.replyTo, replyTo) || other.replyTo == replyTo) &&
(identical(other.repostTo, repostTo) ||
other.repostTo == repostTo) &&
const DeepCollectionEquality()
.equals(other.visibleUsersList, visibleUsersList) &&
.equals(other._visibleUsersList, _visibleUsersList) &&
const DeepCollectionEquality()
.equals(other.invisibleUsersList, invisibleUsersList) &&
.equals(other._invisibleUsersList, _invisibleUsersList) &&
(identical(other.visibility, visibility) ||
other.visibility == visibility) &&
(identical(other.editedAt, editedAt) ||
@@ -762,8 +778,6 @@ class _$SnPostImpl extends _SnPost {
other.totalUpvote == totalUpvote) &&
(identical(other.totalDownvote, totalDownvote) ||
other.totalDownvote == totalDownvote) &&
(identical(other.realmId, realmId) || other.realmId == realmId) &&
const DeepCollectionEquality().equals(other.realm, realm) &&
(identical(other.publisherId, publisherId) ||
other.publisherId == publisherId) &&
(identical(other.publisher, publisher) ||
@@ -787,14 +801,13 @@ class _$SnPostImpl extends _SnPost {
aliasPrefix,
const DeepCollectionEquality().hash(_tags),
const DeepCollectionEquality().hash(_categories),
const DeepCollectionEquality().hash(reactions),
const DeepCollectionEquality().hash(replies),
const DeepCollectionEquality().hash(replyId),
const DeepCollectionEquality().hash(repostId),
const DeepCollectionEquality().hash(replyTo),
const DeepCollectionEquality().hash(repostTo),
const DeepCollectionEquality().hash(visibleUsersList),
const DeepCollectionEquality().hash(invisibleUsersList),
const DeepCollectionEquality().hash(_replies),
replyId,
repostId,
replyTo,
repostTo,
const DeepCollectionEquality().hash(_visibleUsersList),
const DeepCollectionEquality().hash(_invisibleUsersList),
visibility,
editedAt,
pinnedAt,
@@ -804,8 +817,6 @@ class _$SnPostImpl extends _SnPost {
publishedUntil,
totalUpvote,
totalDownvote,
realmId,
const DeepCollectionEquality().hash(realm),
publisherId,
publisher,
metric,
@@ -841,14 +852,13 @@ abstract class _SnPost extends SnPost {
required final String? aliasPrefix,
required final List<dynamic> tags,
required final List<dynamic> categories,
required final dynamic reactions,
required final dynamic replies,
required final dynamic replyId,
required final dynamic repostId,
required final dynamic replyTo,
required final dynamic repostTo,
required final dynamic visibleUsersList,
required final dynamic invisibleUsersList,
required final List<SnPost>? replies,
required final int? replyId,
required final int? repostId,
required final SnPost? replyTo,
required final SnPost? repostTo,
required final List<int>? visibleUsersList,
required final List<int>? invisibleUsersList,
required final int visibility,
required final DateTime? editedAt,
required final DateTime? pinnedAt,
@@ -858,8 +868,6 @@ abstract class _SnPost extends SnPost {
required final DateTime? publishedUntil,
required final int totalUpvote,
required final int totalDownvote,
required final int? realmId,
required final dynamic realm,
required final int publisherId,
required final SnPublisher publisher,
required final SnMetric metric,
@@ -891,21 +899,19 @@ abstract class _SnPost extends SnPost {
@override
List<dynamic> get categories;
@override
dynamic get reactions;
List<SnPost>? get replies;
@override
dynamic get replies;
int? get replyId;
@override
dynamic get replyId;
int? get repostId;
@override
dynamic get repostId;
SnPost? get replyTo;
@override
dynamic get replyTo;
SnPost? get repostTo;
@override
dynamic get repostTo;
List<int>? get visibleUsersList;
@override
dynamic get visibleUsersList;
@override
dynamic get invisibleUsersList;
List<int>? get invisibleUsersList;
@override
int get visibility;
@override
@@ -925,10 +931,6 @@ abstract class _SnPost extends SnPost {
@override
int get totalDownvote;
@override
int? get realmId;
@override
dynamic get realm;
@override
int get publisherId;
@override
SnPublisher get publisher;
@@ -1356,6 +1358,7 @@ SnMetric _$SnMetricFromJson(Map<String, dynamic> json) {
mixin _$SnMetric {
int get replyCount => throw _privateConstructorUsedError;
int get reactionCount => throw _privateConstructorUsedError;
Map<String, int> get reactionList => throw _privateConstructorUsedError;
/// Serializes this SnMetric to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@@ -1372,7 +1375,7 @@ abstract class $SnMetricCopyWith<$Res> {
factory $SnMetricCopyWith(SnMetric value, $Res Function(SnMetric) then) =
_$SnMetricCopyWithImpl<$Res, SnMetric>;
@useResult
$Res call({int replyCount, int reactionCount});
$Res call({int replyCount, int reactionCount, Map<String, int> reactionList});
}
/// @nodoc
@@ -1392,6 +1395,7 @@ class _$SnMetricCopyWithImpl<$Res, $Val extends SnMetric>
$Res call({
Object? replyCount = null,
Object? reactionCount = null,
Object? reactionList = null,
}) {
return _then(_value.copyWith(
replyCount: null == replyCount
@@ -1402,6 +1406,10 @@ class _$SnMetricCopyWithImpl<$Res, $Val extends SnMetric>
? _value.reactionCount
: reactionCount // ignore: cast_nullable_to_non_nullable
as int,
reactionList: null == reactionList
? _value.reactionList
: reactionList // ignore: cast_nullable_to_non_nullable
as Map<String, int>,
) as $Val);
}
}
@@ -1414,7 +1422,7 @@ abstract class _$$SnMetricImplCopyWith<$Res>
__$$SnMetricImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({int replyCount, int reactionCount});
$Res call({int replyCount, int reactionCount, Map<String, int> reactionList});
}
/// @nodoc
@@ -1432,6 +1440,7 @@ class __$$SnMetricImplCopyWithImpl<$Res>
$Res call({
Object? replyCount = null,
Object? reactionCount = null,
Object? reactionList = null,
}) {
return _then(_$SnMetricImpl(
replyCount: null == replyCount
@@ -1442,6 +1451,10 @@ class __$$SnMetricImplCopyWithImpl<$Res>
? _value.reactionCount
: reactionCount // ignore: cast_nullable_to_non_nullable
as int,
reactionList: null == reactionList
? _value._reactionList
: reactionList // ignore: cast_nullable_to_non_nullable
as Map<String, int>,
));
}
}
@@ -1449,7 +1462,11 @@ class __$$SnMetricImplCopyWithImpl<$Res>
/// @nodoc
@JsonSerializable()
class _$SnMetricImpl implements _SnMetric {
const _$SnMetricImpl({required this.replyCount, required this.reactionCount});
const _$SnMetricImpl(
{required this.replyCount,
required this.reactionCount,
final Map<String, int> reactionList = const {}})
: _reactionList = reactionList;
factory _$SnMetricImpl.fromJson(Map<String, dynamic> json) =>
_$$SnMetricImplFromJson(json);
@@ -1458,10 +1475,18 @@ class _$SnMetricImpl implements _SnMetric {
final int replyCount;
@override
final int reactionCount;
final Map<String, int> _reactionList;
@override
@JsonKey()
Map<String, int> get reactionList {
if (_reactionList is EqualUnmodifiableMapView) return _reactionList;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_reactionList);
}
@override
String toString() {
return 'SnMetric(replyCount: $replyCount, reactionCount: $reactionCount)';
return 'SnMetric(replyCount: $replyCount, reactionCount: $reactionCount, reactionList: $reactionList)';
}
@override
@@ -1472,12 +1497,15 @@ class _$SnMetricImpl implements _SnMetric {
(identical(other.replyCount, replyCount) ||
other.replyCount == replyCount) &&
(identical(other.reactionCount, reactionCount) ||
other.reactionCount == reactionCount));
other.reactionCount == reactionCount) &&
const DeepCollectionEquality()
.equals(other._reactionList, _reactionList));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, replyCount, reactionCount);
int get hashCode => Object.hash(runtimeType, replyCount, reactionCount,
const DeepCollectionEquality().hash(_reactionList));
/// Create a copy of SnMetric
/// with the given fields replaced by the non-null parameter values.
@@ -1498,7 +1526,8 @@ class _$SnMetricImpl implements _SnMetric {
abstract class _SnMetric implements SnMetric {
const factory _SnMetric(
{required final int replyCount,
required final int reactionCount}) = _$SnMetricImpl;
required final int reactionCount,
final Map<String, int> reactionList}) = _$SnMetricImpl;
factory _SnMetric.fromJson(Map<String, dynamic> json) =
_$SnMetricImpl.fromJson;
@@ -1507,6 +1536,8 @@ abstract class _SnMetric implements SnMetric {
int get replyCount;
@override
int get reactionCount;
@override
Map<String, int> get reactionList;
/// Create a copy of SnMetric
/// with the given fields replaced by the non-null parameter values.

View File

@@ -20,14 +20,23 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
aliasPrefix: json['alias_prefix'] as String?,
tags: json['tags'] as List<dynamic>,
categories: json['categories'] as List<dynamic>,
reactions: json['reactions'],
replies: json['replies'],
replyId: json['reply_id'],
repostId: json['repost_id'],
replyTo: json['reply_to'],
repostTo: json['repost_to'],
visibleUsersList: json['visible_users_list'],
invisibleUsersList: json['invisible_users_list'],
replies: (json['replies'] as List<dynamic>?)
?.map((e) => SnPost.fromJson(e as Map<String, dynamic>))
.toList(),
replyId: (json['reply_id'] as num?)?.toInt(),
repostId: (json['repost_id'] as num?)?.toInt(),
replyTo: json['reply_to'] == null
? null
: SnPost.fromJson(json['reply_to'] as Map<String, dynamic>),
repostTo: json['repost_to'] == null
? null
: SnPost.fromJson(json['repost_to'] as Map<String, dynamic>),
visibleUsersList: (json['visible_users_list'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
invisibleUsersList: (json['invisible_users_list'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
visibility: (json['visibility'] as num).toInt(),
editedAt: json['edited_at'] == null
? null
@@ -47,8 +56,6 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
: DateTime.parse(json['published_until'] as String),
totalUpvote: (json['total_upvote'] as num).toInt(),
totalDownvote: (json['total_downvote'] as num).toInt(),
realmId: (json['realm_id'] as num?)?.toInt(),
realm: json['realm'],
publisherId: (json['publisher_id'] as num).toInt(),
publisher:
SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
@@ -71,12 +78,11 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'alias_prefix': instance.aliasPrefix,
'tags': instance.tags,
'categories': instance.categories,
'reactions': instance.reactions,
'replies': instance.replies,
'replies': instance.replies?.map((e) => e.toJson()).toList(),
'reply_id': instance.replyId,
'repost_id': instance.repostId,
'reply_to': instance.replyTo,
'repost_to': instance.repostTo,
'reply_to': instance.replyTo?.toJson(),
'repost_to': instance.repostTo?.toJson(),
'visible_users_list': instance.visibleUsersList,
'invisible_users_list': instance.invisibleUsersList,
'visibility': instance.visibility,
@@ -88,8 +94,6 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'published_until': instance.publishedUntil?.toIso8601String(),
'total_upvote': instance.totalUpvote,
'total_downvote': instance.totalDownvote,
'realm_id': instance.realmId,
'realm': instance.realm,
'publisher_id': instance.publisherId,
'publisher': instance.publisher.toJson(),
'metric': instance.metric.toJson(),
@@ -131,12 +135,17 @@ _$SnMetricImpl _$$SnMetricImplFromJson(Map<String, dynamic> json) =>
_$SnMetricImpl(
replyCount: (json['reply_count'] as num).toInt(),
reactionCount: (json['reaction_count'] as num).toInt(),
reactionList: (json['reaction_list'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, (e as num).toInt()),
) ??
const {},
);
Map<String, dynamic> _$$SnMetricImplToJson(_$SnMetricImpl instance) =>
<String, dynamic>{
'reply_count': instance.replyCount,
'reaction_count': instance.reactionCount,
'reaction_list': instance.reactionList,
};
_$SnPublisherImpl _$$SnPublisherImplFromJson(Map<String, dynamic> json) =>

20
lib/types/reaction.dart Normal file
View File

@@ -0,0 +1,20 @@
class ReactInfo {
final String icon;
final int attitude;
const ReactInfo({required this.icon, required this.attitude});
}
const Map<String, ReactInfo> kTemplateReactions = {
'thumb_up': ReactInfo(icon: '👍', attitude: 1),
'thumb_down': ReactInfo(icon: '👎', attitude: 2),
'just_okay': ReactInfo(icon: '😅', attitude: 0),
'cry': ReactInfo(icon: '😭', attitude: 0),
'confuse': ReactInfo(icon: '🧐', attitude: 0),
'clap': ReactInfo(icon: '👏', attitude: 1),
'laugh': ReactInfo(icon: '😂', attitude: 1),
'angry': ReactInfo(icon: '😡', attitude: 2),
'party': ReactInfo(icon: '🎉', attitude: 1),
'joy': ReactInfo(icon: '🤣', attitude: 1),
'pray': ReactInfo(icon: '🙏', attitude: 1),
};

17
lib/types/websocket.dart Normal file
View File

@@ -0,0 +1,17 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'websocket.freezed.dart';
part 'websocket.g.dart';
@freezed
class WebSocketPackage with _$WebSocketPackage {
const factory WebSocketPackage({
@JsonKey(name: 'w') @Default('unknown') String method,
@JsonKey(name: 'e') String? endpoint,
@JsonKey(name: 'm') String? message,
@JsonKey(name: 'p') @Default({}) Map<String, dynamic>? payload,
}) = _WebSocketPackage;
factory WebSocketPackage.fromJson(Map<String, dynamic> json) =>
_$WebSocketPackageFromJson(json);
}

View File

@@ -0,0 +1,252 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'websocket.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
WebSocketPackage _$WebSocketPackageFromJson(Map<String, dynamic> json) {
return _WebSocketPackage.fromJson(json);
}
/// @nodoc
mixin _$WebSocketPackage {
@JsonKey(name: 'w')
String get method => throw _privateConstructorUsedError;
@JsonKey(name: 'e')
String? get endpoint => throw _privateConstructorUsedError;
@JsonKey(name: 'm')
String? get message => throw _privateConstructorUsedError;
@JsonKey(name: 'p')
Map<String, dynamic>? get payload => throw _privateConstructorUsedError;
/// Serializes this WebSocketPackage to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of WebSocketPackage
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$WebSocketPackageCopyWith<WebSocketPackage> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $WebSocketPackageCopyWith<$Res> {
factory $WebSocketPackageCopyWith(
WebSocketPackage value, $Res Function(WebSocketPackage) then) =
_$WebSocketPackageCopyWithImpl<$Res, WebSocketPackage>;
@useResult
$Res call(
{@JsonKey(name: 'w') String method,
@JsonKey(name: 'e') String? endpoint,
@JsonKey(name: 'm') String? message,
@JsonKey(name: 'p') Map<String, dynamic>? payload});
}
/// @nodoc
class _$WebSocketPackageCopyWithImpl<$Res, $Val extends WebSocketPackage>
implements $WebSocketPackageCopyWith<$Res> {
_$WebSocketPackageCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of WebSocketPackage
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? method = null,
Object? endpoint = freezed,
Object? message = freezed,
Object? payload = freezed,
}) {
return _then(_value.copyWith(
method: null == method
? _value.method
: method // ignore: cast_nullable_to_non_nullable
as String,
endpoint: freezed == endpoint
? _value.endpoint
: endpoint // ignore: cast_nullable_to_non_nullable
as String?,
message: freezed == message
? _value.message
: message // ignore: cast_nullable_to_non_nullable
as String?,
payload: freezed == payload
? _value.payload
: payload // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
) as $Val);
}
}
/// @nodoc
abstract class _$$WebSocketPackageImplCopyWith<$Res>
implements $WebSocketPackageCopyWith<$Res> {
factory _$$WebSocketPackageImplCopyWith(_$WebSocketPackageImpl value,
$Res Function(_$WebSocketPackageImpl) then) =
__$$WebSocketPackageImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{@JsonKey(name: 'w') String method,
@JsonKey(name: 'e') String? endpoint,
@JsonKey(name: 'm') String? message,
@JsonKey(name: 'p') Map<String, dynamic>? payload});
}
/// @nodoc
class __$$WebSocketPackageImplCopyWithImpl<$Res>
extends _$WebSocketPackageCopyWithImpl<$Res, _$WebSocketPackageImpl>
implements _$$WebSocketPackageImplCopyWith<$Res> {
__$$WebSocketPackageImplCopyWithImpl(_$WebSocketPackageImpl _value,
$Res Function(_$WebSocketPackageImpl) _then)
: super(_value, _then);
/// Create a copy of WebSocketPackage
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? method = null,
Object? endpoint = freezed,
Object? message = freezed,
Object? payload = freezed,
}) {
return _then(_$WebSocketPackageImpl(
method: null == method
? _value.method
: method // ignore: cast_nullable_to_non_nullable
as String,
endpoint: freezed == endpoint
? _value.endpoint
: endpoint // ignore: cast_nullable_to_non_nullable
as String?,
message: freezed == message
? _value.message
: message // ignore: cast_nullable_to_non_nullable
as String?,
payload: freezed == payload
? _value._payload
: payload // ignore: cast_nullable_to_non_nullable
as Map<String, dynamic>?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$WebSocketPackageImpl implements _WebSocketPackage {
const _$WebSocketPackageImpl(
{@JsonKey(name: 'w') this.method = 'unknown',
@JsonKey(name: 'e') this.endpoint,
@JsonKey(name: 'm') this.message,
@JsonKey(name: 'p') final Map<String, dynamic>? payload = const {}})
: _payload = payload;
factory _$WebSocketPackageImpl.fromJson(Map<String, dynamic> json) =>
_$$WebSocketPackageImplFromJson(json);
@override
@JsonKey(name: 'w')
final String method;
@override
@JsonKey(name: 'e')
final String? endpoint;
@override
@JsonKey(name: 'm')
final String? message;
final Map<String, dynamic>? _payload;
@override
@JsonKey(name: 'p')
Map<String, dynamic>? get payload {
final value = _payload;
if (value == null) return null;
if (_payload is EqualUnmodifiableMapView) return _payload;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(value);
}
@override
String toString() {
return 'WebSocketPackage(method: $method, endpoint: $endpoint, message: $message, payload: $payload)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$WebSocketPackageImpl &&
(identical(other.method, method) || other.method == method) &&
(identical(other.endpoint, endpoint) ||
other.endpoint == endpoint) &&
(identical(other.message, message) || other.message == message) &&
const DeepCollectionEquality().equals(other._payload, _payload));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, method, endpoint, message,
const DeepCollectionEquality().hash(_payload));
/// Create a copy of WebSocketPackage
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$WebSocketPackageImplCopyWith<_$WebSocketPackageImpl> get copyWith =>
__$$WebSocketPackageImplCopyWithImpl<_$WebSocketPackageImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$WebSocketPackageImplToJson(
this,
);
}
}
abstract class _WebSocketPackage implements WebSocketPackage {
const factory _WebSocketPackage(
{@JsonKey(name: 'w') final String method,
@JsonKey(name: 'e') final String? endpoint,
@JsonKey(name: 'm') final String? message,
@JsonKey(name: 'p') final Map<String, dynamic>? payload}) =
_$WebSocketPackageImpl;
factory _WebSocketPackage.fromJson(Map<String, dynamic> json) =
_$WebSocketPackageImpl.fromJson;
@override
@JsonKey(name: 'w')
String get method;
@override
@JsonKey(name: 'e')
String? get endpoint;
@override
@JsonKey(name: 'm')
String? get message;
@override
@JsonKey(name: 'p')
Map<String, dynamic>? get payload;
/// Create a copy of WebSocketPackage
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$WebSocketPackageImplCopyWith<_$WebSocketPackageImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'websocket.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$WebSocketPackageImpl _$$WebSocketPackageImplFromJson(
Map<String, dynamic> json) =>
_$WebSocketPackageImpl(
method: json['w'] as String? ?? 'unknown',
endpoint: json['e'] as String?,
message: json['m'] as String?,
payload: json['p'] as Map<String, dynamic>? ?? const {},
);
Map<String, dynamic> _$$WebSocketPackageImplToJson(
_$WebSocketPackageImpl instance) =>
<String, dynamic>{
'w': instance.method,
'e': instance.endpoint,
'm': instance.message,
'p': instance.payload,
};

View File

@@ -28,6 +28,9 @@ class AttachmentDetailPopup extends StatelessWidget {
tag: 'attachment-${data.rid}-${heroTag ?? uuid.v4()}',
child: PhotoView(
key: Key('attachment-detail-${data.rid}-$heroTag'),
backgroundDecoration: BoxDecoration(
color: Colors.black.withOpacity(0.7),
),
imageProvider: UniversalImage.provider(
sn.getAttachmentUrl(data.rid),
),

View File

@@ -1,6 +1,12 @@
import 'dart:ui';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/attachment.dart';
import 'package:surface/widgets/attachment/attachment_detail.dart';
@@ -23,15 +29,11 @@ class AttachmentItem extends StatelessWidget {
case 'image':
return Hero(
tag: 'attachment-${data.rid}-$heroTag',
child: LayoutBuilder(builder: (context, constraints) {
return UniversalImage(
sn.getAttachmentUrl(data.rid),
key: Key('attachment-${data.rid}-$heroTag'),
fit: BoxFit.cover,
cacheHeight: constraints.maxHeight,
cacheWidth: constraints.maxWidth,
);
}),
child: AutoResizeUniversalImage(
sn.getAttachmentUrl(data.rid),
key: Key('attachment-${data.rid}-$heroTag'),
fit: BoxFit.cover,
),
);
default:
return const Placeholder();
@@ -43,6 +45,12 @@ class AttachmentItem extends StatelessWidget {
final uuid = Uuid();
final heroTag = uuid.v4();
if (data.isMature) {
return _AttachmentItemSensitiveBlur(
child: _buildContent(context, heroTag),
);
}
if (isExpandable) {
return GestureDetector(
child: _buildContent(context, heroTag),
@@ -58,3 +66,87 @@ class AttachmentItem extends StatelessWidget {
return _buildContent(context, heroTag);
}
}
class _AttachmentItemSensitiveBlur extends StatefulWidget {
final Widget child;
const _AttachmentItemSensitiveBlur({super.key, required this.child});
@override
State<_AttachmentItemSensitiveBlur> createState() =>
_AttachmentItemSensitiveBlurState();
}
class _AttachmentItemSensitiveBlurState
extends State<_AttachmentItemSensitiveBlur> {
bool _doesShow = false;
@override
Widget build(BuildContext context) {
return Stack(
children: [
widget.child,
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40),
child: Container(
color: Colors.black.withOpacity(0.5),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Symbols.visibility_off,
color: Colors.white,
size: 32,
),
const Gap(8),
Text('sensitiveContent')
.tr()
.fontSize(20)
.textColor(Colors.white)
.bold(),
Text('sensitiveContentDescription')
.tr()
.fontSize(14)
.textColor(Colors.white.withOpacity(0.8)),
const Gap(16),
InkWell(
child: Text('sensitiveContentReveal')
.tr()
.textColor(Colors.white),
onTap: () {
setState(() => _doesShow = !_doesShow);
},
),
],
),
),
),
)
.opacity(_doesShow ? 0 : 1, animate: true)
.animate(const Duration(milliseconds: 300), Curves.easeInOut),
if (_doesShow)
Positioned(
top: 0,
left: 0,
child: InkWell(
child: Icon(
Symbols.visibility_off,
color: Colors.white,
shadows: [
BoxShadow(
color: Colors.black.withOpacity(0.5),
blurRadius: 3,
offset: Offset(0, 1.5),
),
],
).padding(all: 12),
onTap: () {
setState(() => _doesShow = !_doesShow);
},
),
),
],
);
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:math' as math;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
@@ -10,15 +8,16 @@ import 'package:surface/widgets/attachment/attachment_item.dart';
class AttachmentList extends StatelessWidget {
final List<SnAttachment> data;
final bool? bordered;
final double? maxListHeight;
final double? maxHeight;
final EdgeInsets? listPadding;
const AttachmentList({
super.key,
required this.data,
this.bordered,
this.maxListHeight,
this.maxHeight,
this.listPadding,
});
static const double kMaxListItemWidth = 520;
static const BorderRadius kDefaultRadius =
BorderRadius.all(Radius.circular(8));
@@ -27,26 +26,31 @@ class AttachmentList extends StatelessWidget {
final borderSide = (bordered ?? false)
? BorderSide(width: 1, color: Theme.of(context).dividerColor)
: BorderSide.none;
final backgroundColor = Theme.of(context).colorScheme.surfaceContainer;
final constraints = BoxConstraints(
minWidth: 80,
maxHeight: maxHeight ?? double.infinity,
);
if (data.isEmpty) return const SizedBox.shrink();
if (data.length == 1) {
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) {
return Container(
constraints: BoxConstraints(
maxWidth: math.min(
MediaQuery.of(context).size.width - 20,
kMaxListItemWidth,
),
),
decoration: BoxDecoration(
border: Border(top: borderSide, bottom: borderSide),
borderRadius: kDefaultRadius,
),
child: AspectRatio(
aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1,
child: ClipRRect(
return Padding(
// Single child list-like displaying
padding: listPadding ?? EdgeInsets.zero,
child: Container(
constraints: constraints,
decoration: BoxDecoration(
color: backgroundColor,
border: Border(top: borderSide, bottom: borderSide),
borderRadius: kDefaultRadius,
child: AttachmentItem(data: data[0], isExpandable: true),
),
child: AspectRatio(
aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1,
child: ClipRRect(
borderRadius: kDefaultRadius,
child: AttachmentItem(data: data[0], isExpandable: true),
),
),
),
);
@@ -54,6 +58,7 @@ class AttachmentList extends StatelessWidget {
return Container(
decoration: BoxDecoration(
color: backgroundColor,
border: Border(top: borderSide, bottom: borderSide),
),
child: AspectRatio(
@@ -64,35 +69,43 @@ class AttachmentList extends StatelessWidget {
}
return Container(
constraints: BoxConstraints(maxHeight: maxListHeight ?? 320),
constraints: BoxConstraints(maxHeight: maxHeight ?? 320),
child: ScrollConfiguration(
behavior: _AttachmentListScrollBehavior(),
child: ListView.separated(
shrinkWrap: true,
itemCount: data.length,
itemBuilder: (context, idx) {
return Container(
constraints: BoxConstraints(
maxWidth: math.min(
MediaQuery.of(context).size.width - 20,
kMaxListItemWidth,
return Stack(
children: [
Container(
constraints: constraints,
decoration: BoxDecoration(
color: backgroundColor,
border: Border(top: borderSide, bottom: borderSide),
borderRadius: kDefaultRadius,
),
child: AspectRatio(
aspectRatio: data[idx].metadata['ratio']?.toDouble() ?? 1,
child: ClipRRect(
borderRadius: kDefaultRadius,
child:
AttachmentItem(data: data[idx], isExpandable: true),
),
),
),
),
decoration: BoxDecoration(
border: Border(top: borderSide, bottom: borderSide),
borderRadius: kDefaultRadius,
),
child: AspectRatio(
aspectRatio: data[idx].metadata['ratio']?.toDouble() ?? 1,
child: ClipRRect(
borderRadius: kDefaultRadius,
child: AttachmentItem(data: data[idx], isExpandable: true),
Positioned(
right: 12,
bottom: 12,
child: Chip(
label: Text('${idx + 1}/${data.length}'),
),
),
),
],
);
},
separatorBuilder: (context, index) => const Gap(8),
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: listPadding,
physics: const BouncingScrollPhysics(),
scrollDirection: Axis.horizontal,
),

View File

@@ -0,0 +1,55 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/providers/websocket.dart';
class ConnectionIndicator extends StatelessWidget {
const ConnectionIndicator({super.key});
@override
Widget build(BuildContext context) {
final ws = context.watch<WebSocketProvider>();
return ListenableBuilder(
listenable: ws,
builder: (context, _) {
final ua = context.read<UserProvider>();
return Container(
padding: EdgeInsets.only(
bottom: 8,
top: MediaQuery.of(context).padding.top + 8,
left: 24,
right: 24,
),
color: Theme.of(context).colorScheme.secondaryContainer,
child: ua.isAuthorized
? Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (ws.isBusy)
Text('serverConnecting').tr().textColor(
Theme.of(context).colorScheme.onSecondaryContainer)
else if (!ws.isConnected)
Text('serverDisconnected').tr().textColor(
Theme.of(context).colorScheme.onSecondaryContainer),
],
)
: const SizedBox.shrink(),
)
.height(
(ws.isBusy || !ws.isConnected) && ua.isAuthorized
? MediaQuery.of(context).padding.top + 30
: 0,
animate: true)
.animate(
const Duration(milliseconds: 300),
Curves.easeInOut,
);
},
);
}
}

View File

@@ -1,6 +1,8 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:surface/widgets/navigation/app_destinations.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/navigation.dart';
class AppBottomNavigationBar extends StatefulWidget {
const AppBottomNavigationBar({super.key});
@@ -10,23 +12,46 @@ class AppBottomNavigationBar extends StatefulWidget {
}
class _AppBottomNavigationBarState extends State<AppBottomNavigationBar> {
int _currentIndex = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context
.read<NavigationProvider>()
.autoDetectIndex(GoRouter.maybeOf(context));
});
}
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
currentIndex: _currentIndex,
type: BottomNavigationBarType.fixed,
showUnselectedLabels: false,
items: appDestinations.map((ele) {
return BottomNavigationBarItem(
icon: ele.icon,
label: ele.label,
final nav = context.watch<NavigationProvider>();
return ListenableBuilder(
listenable: nav,
builder: (context, _) {
if (!nav.isIndexInRange(0, nav.pinnedDestinationCount)) {
return const SizedBox.shrink();
}
final destinations = [
...nav.destinations.where((ele) => ele.isPinned),
];
return BottomNavigationBar(
currentIndex: nav.getIndexInRange(0, nav.pinnedDestinationCount),
type: BottomNavigationBarType.fixed,
showUnselectedLabels: false,
items: destinations.map((ele) {
return BottomNavigationBarItem(
icon: ele.icon,
label: ele.label.tr(),
);
}).toList(),
onTap: (idx) {
nav.setIndex(idx);
GoRouter.of(context).goNamed(destinations[idx].screen);
},
);
}).toList(),
onTap: (idx) {
setState(() => _currentIndex = idx);
GoRouter.of(context).goNamed(appDestinations[idx].screen);
},
);
}

View File

@@ -1,33 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class AppNavDestination {
final String label;
final String screen;
final Widget icon;
AppNavDestination({
required this.label,
required this.screen,
required this.icon,
});
}
List<AppNavDestination> appDestinations = [
AppNavDestination(
icon: Icon(Symbols.home, weight: 400, opticalSize: 20),
screen: 'home',
label: tr('screenHome'),
),
AppNavDestination(
icon: Icon(Symbols.explore, weight: 400, opticalSize: 20),
screen: 'explore',
label: tr('screenExplore'),
),
AppNavDestination(
icon: Icon(Symbols.account_circle, weight: 400, opticalSize: 20),
screen: 'account',
label: tr('screenAccount'),
),
];

View File

@@ -0,0 +1,85 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/navigation.dart';
class AppNavigationDrawer extends StatefulWidget {
final double? elevation;
const AppNavigationDrawer({super.key, this.elevation});
@override
State<AppNavigationDrawer> createState() => _AppNavigationDrawerState();
}
class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context
.read<NavigationProvider>()
.autoDetectIndex(GoRouter.maybeOf(context));
});
}
@override
Widget build(BuildContext context) {
final nav = context.watch<NavigationProvider>();
final backgroundColor = ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? Theme.of(context).colorScheme.surface
: null;
return ListenableBuilder(
listenable: nav,
builder: (context, _) {
final destinations = [
...nav.destinations.where((ele) => ele.isPinned),
...nav.destinations.where((ele) => !ele.isPinned),
];
return NavigationDrawer(
elevation: widget.elevation,
backgroundColor: backgroundColor,
selectedIndex: nav.currentIndex,
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Solar Network').bold(),
Text('Canary Preview 2.0α').fontSize(12).textColor(
Theme.of(context).colorScheme.onSurface.withOpacity(0.5)),
],
).padding(
horizontal: 32,
top: MediaQuery.of(context).padding.top > 16 ? 8 : 16,
bottom: 16,
),
...destinations.where((ele) => ele.isPinned).map((ele) {
return NavigationDrawerDestination(
icon: ele.icon,
label: Text(ele.label).tr(),
);
}),
const Divider(),
...destinations.where((ele) => !ele.isPinned).map((ele) {
return NavigationDrawerDestination(
icon: ele.icon,
label: Text(ele.label).tr(),
);
}),
],
onDestinationSelected: (idx) {
nav.setIndex(idx);
GoRouter.of(context).goNamed(destinations[idx].screen);
Scaffold.of(context).closeDrawer();
},
);
},
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/navigation.dart';
class AppRailNavigation extends StatefulWidget {
const AppRailNavigation({super.key});
@override
State<AppRailNavigation> createState() => _AppRailNavigationState();
}
class _AppRailNavigationState extends State<AppRailNavigation> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context
.read<NavigationProvider>()
.autoDetectIndex(GoRouter.maybeOf(context));
});
}
@override
Widget build(BuildContext context) {
final nav = context.watch<NavigationProvider>();
return ListenableBuilder(
listenable: nav,
builder: (context, _) {
final destinations =
nav.destinations.where((ele) => ele.isPinned).toList();
return NavigationRail(
selectedIndex: nav.currentIndex,
destinations: [
...destinations.where((ele) => ele.isPinned).map((ele) {
return NavigationRailDestination(
icon: ele.icon,
label: Text(ele.label).tr(),
);
}),
],
trailing: Expanded(
child: Align(
alignment: Alignment.bottomCenter,
child: StyledWidget(
IconButton(
icon: const Icon(Symbols.menu),
onPressed: () {
Scaffold.of(context).openDrawer();
},
),
).padding(bottom: 16),
),
),
onDestinationSelected: (idx) {
nav.setIndex(idx);
GoRouter.of(context).goNamed(destinations[idx].screen);
},
);
},
);
}
}

View File

@@ -2,26 +2,23 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:surface/widgets/connection_indicator.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/navigation/app_background.dart';
import 'package:surface/widgets/navigation/app_bottom_navigation.dart';
import 'package:surface/widgets/navigation/app_drawer_navigation.dart';
import 'package:surface/widgets/navigation/app_rail_navigation.dart';
class AppScaffold extends StatelessWidget {
final PreferredSizeWidget? appBar;
final FloatingActionButtonLocation? floatingActionButtonLocation;
final Widget? floatingActionButton;
final String? title;
final Widget? body;
final bool autoImplyAppBar;
final bool showAppBar;
final bool showBottomNavigation;
const AppScaffold({
super.key,
this.appBar,
this.floatingActionButton,
this.floatingActionButtonLocation,
this.title,
this.body,
this.autoImplyAppBar = false,
this.showAppBar = true,
this.showBottomNavigation = false,
});
@@ -32,26 +29,65 @@ class AppScaffold extends StatelessWidget {
: false;
final state = GoRouter.maybeOf(context);
final autoTitle = state != null
? 'screen${state.routerDelegate.currentConfiguration.last.route.name?.capitalize()}'
: 'screen';
return Scaffold(
appBar: showAppBar
? AppBar(
title: Text(title ?? autoTitle.tr()),
)
: null,
body: body,
bottomNavigationBar:
isShowBottomNavigation ? AppBottomNavigationBar() : null,
);
}
}
class AppRootScaffold extends StatelessWidget {
final Widget body;
const AppRootScaffold({super.key, required this.body});
@override
Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final isCollapseDrawer =
ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE);
final isExpandDrawer = ResponsiveBreakpoints.of(context).largerThan(TABLET);
final innerWidget = isCollapseDrawer
? body
: Row(
children: [
Container(
decoration: BoxDecoration(
border: Border(
right: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
),
),
child: isExpandDrawer
? AppNavigationDrawer(elevation: 0)
: AppRailNavigation(),
),
Expanded(child: body),
],
);
return AppBackground(
child: Scaffold(
appBar: appBar ??
(autoImplyAppBar
? AppBar(
title: title != null
? Text(title!)
: state != null
? Text(
('screen${state.routerDelegate.currentConfiguration.last.route.name?.capitalize()}')
.tr(),
)
: null)
: null),
body: body,
floatingActionButtonLocation: floatingActionButtonLocation,
floatingActionButton: floatingActionButton,
bottomNavigationBar:
isShowBottomNavigation ? AppBottomNavigationBar() : null,
body: Column(
children: [
ConnectionIndicator(),
Expanded(child: innerWidget),
],
),
drawer: !isExpandDrawer ? AppNavigationDrawer() : null,
),
);
}

View File

@@ -7,19 +7,26 @@ import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_mini_editor.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostCommentSliverList extends StatefulWidget {
final int parentPostId;
const PostCommentSliverList({super.key, required this.parentPostId});
final double? maxWidth;
const PostCommentSliverList({
super.key,
required this.parentPostId,
this.maxWidth,
});
@override
State<PostCommentSliverList> createState() => _PostCommentSliverListState();
State<PostCommentSliverList> createState() => PostCommentSliverListState();
}
class _PostCommentSliverListState extends State<PostCommentSliverList> {
class PostCommentSliverListState extends State<PostCommentSliverList> {
bool _isBusy = true;
final List<SnPost> _posts = List.empty(growable: true);
@@ -67,6 +74,11 @@ class _PostCommentSliverListState extends State<PostCommentSliverList> {
if (mounted) setState(() => _isBusy = false);
}
Future<void> refresh() async {
_posts.clear();
_fetchPosts();
}
@override
void initState() {
super.initState();
@@ -82,7 +94,12 @@ class _PostCommentSliverListState extends State<PostCommentSliverList> {
onFetchData: _fetchPosts,
itemBuilder: (context, idx) {
return GestureDetector(
child: PostItem(data: _posts[idx]),
child: Container(
constraints: BoxConstraints(
maxWidth: widget.maxWidth ?? double.infinity,
),
child: PostItem(data: _posts[idx]),
).center(),
onTap: () {
GoRouter.of(context).pushNamed(
'postDetail',
@@ -97,7 +114,7 @@ class _PostCommentSliverListState extends State<PostCommentSliverList> {
}
}
class PostCommentListPopup extends StatelessWidget {
class PostCommentListPopup extends StatefulWidget {
final int postId;
final int commentCount;
const PostCommentListPopup({
@@ -106,8 +123,18 @@ class PostCommentListPopup extends StatelessWidget {
this.commentCount = 0,
});
@override
State<PostCommentListPopup> createState() => _PostCommentListPopupState();
}
class _PostCommentListPopupState extends State<PostCommentListPopup> {
final GlobalKey<PostCommentSliverListState> _childListKey = GlobalKey();
@override
Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -117,14 +144,37 @@ class PostCommentListPopup extends StatelessWidget {
const Icon(Symbols.comment, size: 24),
const Gap(16),
Text('postCommentsDetailed')
.plural(commentCount)
.plural(widget.commentCount)
.textStyle(Theme.of(context).textTheme.titleLarge!),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Expanded(
child: CustomScrollView(
slivers: [
PostCommentSliverList(parentPostId: postId),
if (ua.isAuthorized)
SliverToBoxAdapter(
child: Container(
height: 240,
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
),
),
child: PostMiniEditor(
postReplyId: widget.postId,
onPost: () {
_childListKey.currentState!.refresh();
},
),
),
),
PostCommentSliverList(
key: _childListKey,
parentPostId: widget.postId,
),
],
),
),

View File

@@ -12,16 +12,25 @@ import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:gap/gap.dart';
import 'package:surface/widgets/post/post_comment_list.dart';
import 'package:surface/widgets/post/post_reaction.dart';
class PostItem extends StatelessWidget {
final SnPost data;
final bool showReactions;
final bool showComments;
final Function(SnPost data)? onChanged;
const PostItem({
super.key,
required this.data,
this.showReactions = true,
this.showComments = true,
this.onChanged,
});
void _onChanged(SnPost data) {
if (onChanged != null) onChanged!(data);
}
@override
Widget build(BuildContext context) {
return Column(
@@ -29,13 +38,23 @@ class PostItem extends StatelessWidget {
children: [
_PostContentHeader(data: data).padding(horizontal: 12, vertical: 8),
_PostContentBody(data: data.body).padding(horizontal: 16, bottom: 6),
if (data.repostTo != null)
_PostQuoteContent(child: data.repostTo!).padding(
horizontal: 12,
),
if (data.preload?.attachments?.isNotEmpty ?? true)
AttachmentList(
data: data.preload!.attachments!,
bordered: true,
maxHeight: 520,
listPadding: const EdgeInsets.symmetric(horizontal: 12),
),
_PostBottomAction(data: data, showComments: showComments)
.padding(left: 12, right: 18),
_PostBottomAction(
data: data,
showComments: showComments,
showReactions: showReactions,
onChanged: _onChanged,
).padding(left: 12, right: 18),
],
);
}
@@ -44,7 +63,14 @@ class PostItem extends StatelessWidget {
class _PostBottomAction extends StatelessWidget {
final SnPost data;
final bool showComments;
const _PostBottomAction({required this.data, required this.showComments});
final bool showReactions;
final Function(SnPost data) onChanged;
const _PostBottomAction({
required this.data,
required this.showComments,
required this.showReactions,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
@@ -56,16 +82,40 @@ class _PostBottomAction extends StatelessWidget {
children: [
Row(
children: [
InkWell(
child: Row(
children: [
Icon(Symbols.add_reaction, size: 20, color: iconColor),
const Gap(8),
Text('postReact').tr(),
],
).padding(horizontal: 8, vertical: 8),
onTap: () {},
),
if (showReactions)
InkWell(
child: Row(
children: [
Icon(Symbols.add_reaction, size: 20, color: iconColor),
const Gap(8),
if (data.totalDownvote > 0 || data.totalUpvote > 0)
Text('postReactionPoints').plural(
data.totalUpvote - data.totalDownvote,
)
else
Text('postReact').tr(),
],
).padding(horizontal: 8, vertical: 8),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => PostReactionPopup(
data: data,
onChanged: (value, isPositive, delta) {
onChanged(data.copyWith(
totalUpvote: isPositive
? data.totalUpvote + delta
: data.totalUpvote,
totalDownvote: !isPositive
? data.totalDownvote + delta
: data.totalDownvote,
metric: data.metric.copyWith(reactionList: value),
));
},
),
);
},
),
if (showComments)
InkWell(
child: Row(
@@ -104,7 +154,13 @@ class _PostBottomAction extends StatelessWidget {
class _PostContentHeader extends StatelessWidget {
final SnPost data;
const _PostContentHeader({required this.data});
final bool isCompact;
final bool showActions;
const _PostContentHeader({
required this.data,
this.isCompact = false,
this.showActions = true,
});
@override
Widget build(BuildContext context) {
@@ -113,13 +169,16 @@ class _PostContentHeader extends StatelessWidget {
return Row(
children: [
AccountImage(content: data.publisher.avatar),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
AccountImage(
content: data.publisher.avatar,
radius: isCompact ? 12 : 20,
),
Gap(isCompact ? 8 : 12),
if (isCompact)
Row(
children: [
Text(data.publisher.nick).bold(),
const Gap(4),
Row(
children: [
Text('@${data.publisher.name}').fontSize(13),
@@ -130,86 +189,104 @@ class _PostContentHeader extends StatelessWidget {
],
).opacity(0.8),
],
)
else
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(data.publisher.nick).bold(),
Row(
children: [
Text('@${data.publisher.name}').fontSize(13),
const Gap(4),
Text(RelativeTime(context).format(
data.publishedAt ?? data.createdAt,
)).fontSize(13),
],
).opacity(0.8),
],
),
),
),
PopupMenuButton(
icon: const Icon(Symbols.more_horiz),
style: const ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
itemBuilder: (BuildContext context) => <PopupMenuEntry>[
if (isAuthor)
if (showActions)
PopupMenuButton(
icon: const Icon(Symbols.more_horiz),
style: const ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
itemBuilder: (BuildContext context) => <PopupMenuEntry>[
if (isAuthor)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Gap(16),
Text('edit').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'postEditor',
pathParameters: {'mode': data.typePlural},
queryParameters: {'editing': data.id.toString()},
);
},
),
if (isAuthor)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete),
const Gap(16),
Text('delete').tr(),
],
),
),
if (isAuthor) const PopupMenuDivider(),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.edit),
const Icon(Symbols.reply),
const Gap(16),
Text('edit').tr(),
Text('reply').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'postEditor',
pathParameters: {'mode': data.typePlural},
queryParameters: {'editing': data.id.toString()},
queryParameters: {'replying': data.id.toString()},
);
},
),
if (isAuthor)
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete),
const Icon(Symbols.forward),
const Gap(16),
Text('delete').tr(),
Text('repost').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'postEditor',
pathParameters: {'mode': data.typePlural},
queryParameters: {'reposting': data.id.toString()},
);
},
),
const PopupMenuDivider(),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.flag),
const Gap(16),
Text('report').tr(),
],
),
),
if (isAuthor) const PopupMenuDivider(),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.reply),
const Gap(16),
Text('reply').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'postEditor',
pathParameters: {'mode': data.typePlural},
queryParameters: {'replying': data.id.toString()},
);
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.forward),
const Gap(16),
Text('repost').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'postEditor',
pathParameters: {'mode': data.typePlural},
queryParameters: {'reposting': data.id.toString()},
);
},
),
const PopupMenuDivider(),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.flag),
const Gap(16),
Text('report').tr(),
],
),
),
],
),
],
),
],
);
}
@@ -225,3 +302,29 @@ class _PostContentBody extends StatelessWidget {
return MarkdownTextContent(content: data['content']);
}
}
class _PostQuoteContent extends StatelessWidget {
final SnPost child;
const _PostQuoteContent({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
_PostContentHeader(data: child, isCompact: true, showActions: false)
.padding(bottom: 4),
_PostContentBody(data: child.body),
],
),
);
}
}

View File

@@ -1,10 +1,236 @@
import 'package:flutter/widgets.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/controllers/post_write_controller.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart';
class PostMiniEditor extends StatelessWidget {
const PostMiniEditor({super.key});
class PostMiniEditor extends StatefulWidget {
final int? postReplyId;
final Function? onPost;
const PostMiniEditor({super.key, this.postReplyId, this.onPost});
@override
State<PostMiniEditor> createState() => _PostMiniEditorState();
}
class _PostMiniEditorState extends State<PostMiniEditor> {
final PostWriteController _writeController = PostWriteController();
bool _isFetching = false;
bool get _isLoading => _isFetching || _writeController.isLoading;
List<SnPublisher>? _publishers;
Future<void> _fetchPublishers() async {
setState(() => _isFetching = true);
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/publishers');
_publishers = List<SnPublisher>.from(
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
);
_writeController.setPublisher(_publishers?.firstOrNull);
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isFetching = false);
}
}
@override
void initState() {
super.initState();
_fetchPublishers();
_writeController.fetchRelatedPost(
context,
replying: widget.postReplyId,
);
}
@override
void dispose() {
_writeController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Placeholder();
return ListenableBuilder(
listenable: _writeController,
builder: (context, _) {
return Column(
children: [
DropdownButtonHideUnderline(
child: DropdownButton2<SnPublisher>(
isExpanded: true,
hint: Text(
'fieldPostPublisher',
style: TextStyle(
fontSize: 14,
color: Theme.of(context).hintColor,
),
).tr(),
items: <DropdownMenuItem<SnPublisher>>[
...(_publishers?.map(
(item) => DropdownMenuItem<SnPublisher>(
enabled: _writeController.editingPost == null,
value: item,
child: Row(
children: [
AccountImage(content: item.avatar, radius: 16),
const Gap(8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(item.nick).textStyle(
Theme.of(context)
.textTheme
.bodyMedium!),
Text('@${item.name}')
.textStyle(Theme.of(context)
.textTheme
.bodySmall!)
.fontSize(12),
],
),
),
],
),
),
) ??
[]),
DropdownMenuItem<SnPublisher>(
value: null,
child: Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: Colors.transparent,
foregroundColor:
Theme.of(context).colorScheme.onSurface,
child: const Icon(Symbols.add),
),
const Gap(8),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('publishersNew').tr().textStyle(
Theme.of(context).textTheme.bodyMedium!),
],
),
),
],
),
),
],
value: _writeController.publisher,
onChanged: (SnPublisher? value) {
if (value == null) {
GoRouter.of(context)
.pushNamed('accountPublisherNew')
.then((value) {
if (value == true) {
_publishers = null;
_fetchPublishers();
}
});
} else {
_writeController.setPublisher(value);
}
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(right: 16),
height: 48,
),
menuItemStyleData: const MenuItemStyleData(
height: 48,
),
),
),
const Divider(height: 1),
const Gap(8),
Expanded(
child: TextField(
controller: _writeController.contentController,
maxLines: null,
decoration: InputDecoration(
hintText: 'fieldPostContent'.tr(),
hintStyle: TextStyle(fontSize: 14),
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
),
border: InputBorder.none,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Gap(8),
LoadingIndicator(isActive: _isLoading),
if (_writeController.isBusy && _writeController.progress != null)
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: _writeController.progress),
duration: Duration(milliseconds: 300),
builder: (context, value, _) =>
LinearProgressIndicator(value: value, minHeight: 2),
)
else if (_writeController.isBusy)
const LinearProgressIndicator(value: null, minHeight: 2),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(
Symbols.launch,
color: Theme.of(context).colorScheme.secondary,
),
onPressed: () {
GoRouter.of(context).pushNamed(
'postEditor',
pathParameters: {'mode': 'stories'},
queryParameters: {
if (widget.postReplyId != null)
'replying': widget.postReplyId.toString(),
},
);
},
),
TextButton.icon(
onPressed: (_writeController.isBusy ||
_writeController.publisher == null)
? null
: () {
_writeController.post(context).then((_) {
if (!context.mounted) return;
if (widget.onPost != null) widget.onPost!();
context.showSnackbar('postPosted'.tr());
_writeController.reset();
});
},
icon: const Icon(Symbols.send),
label: Text('postPublish').tr(),
),
],
).padding(left: 12, right: 16, bottom: 4),
],
);
});
}
}

View File

@@ -0,0 +1,128 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart';
import 'package:surface/types/reaction.dart';
import 'package:surface/widgets/dialog.dart';
class PostReactionPopup extends StatefulWidget {
final SnPost data;
final Function(Map<String, int> value, bool isPositive, int delta)? onChanged;
const PostReactionPopup({super.key, required this.data, this.onChanged});
@override
State<PostReactionPopup> createState() => _PostReactionPopupState();
}
class _PostReactionPopupState extends State<PostReactionPopup> {
bool _isSubmitting = false;
late Map<String, int> _reactions;
Future<void> _reactPost(String symbol, int attitude) async {
if (_isSubmitting) return;
final sn = context.read<SnNetworkProvider>();
try {
setState(() => _isSubmitting = true);
final resp = await sn.client.post(
'/cgi/co/posts/${widget.data.id}/react',
data: {
'symbol': symbol,
'attitude': attitude,
},
);
if (resp.statusCode == 201) {
_reactions[symbol] = (_reactions[symbol] ?? 0) + 1;
// ignore: use_build_context_synchronously
if (context.mounted) context.showSnackbar('postReactCompleted'.tr());
if (widget.onChanged != null) {
widget.onChanged!(
_reactions,
kTemplateReactions[symbol]!.attitude == 1,
1,
);
}
} else if (resp.statusCode == 204) {
_reactions[symbol] = (_reactions[symbol] ?? 0) - 1;
// ignore: use_build_context_synchronously
if (context.mounted) context.showSnackbar('postReactUncompleted'.tr());
if (widget.onChanged != null) {
widget.onChanged!(
_reactions,
kTemplateReactions[symbol]!.attitude == 1,
-1,
);
}
}
} catch (err) {
// ignore: use_build_context_synchronously
if (context.mounted) context.showErrorDialog(err);
} finally {
setState(() => _isSubmitting = false);
}
}
@override
void initState() {
super.initState();
_reactions = Map.from(widget.data.metric.reactionList);
}
@override
Widget build(BuildContext context) {
return SizedBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.mood, size: 24),
const Gap(16),
Text('postReactions')
.tr()
.textStyle(Theme.of(context).textTheme.titleLarge!),
],
).padding(horizontal: 20, top: 16, bottom: 12),
Expanded(
child: GridView.count(
crossAxisSpacing: 4,
mainAxisSpacing: 4,
crossAxisCount: 4,
children: kTemplateReactions.entries.map((e) {
return InkWell(
onTap: () {
if (widget.onChanged == null) return;
_reactPost(e.key, e.value.attitude).then((_) {
if (context.mounted) Navigator.pop(context);
});
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(e.value.icon).fontSize(40),
Text(
e.key,
style: const TextStyle(fontFamily: 'monospace'),
),
const Gap(6),
Text(
'x${_reactions[e.key]?.toString() ?? '0'}',
style: const TextStyle(fontWeight: FontWeight.bold),
),
],
),
);
}).toList(),
),
),
],
),
);
}
}

View File

@@ -30,67 +30,21 @@ class UniversalImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final double? resizeHeight =
cacheHeight != null ? (cacheHeight! * devicePixelRatio) : null;
final double? resizeWidth =
cacheWidth != null ? (cacheWidth! * devicePixelRatio) : null;
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) {
return CachedNetworkImage(
imageUrl: url,
width: width,
height: height,
fit: fit,
memCacheHeight: cacheHeight != null
? (cacheHeight! * devicePixelRatio).round()
: null,
memCacheWidth: cacheWidth != null
? (cacheWidth! * devicePixelRatio).round()
: null,
progressIndicatorBuilder: noProgressIndicator
? null
: (context, url, downloadProgress) => Center(
child: TweenAnimationBuilder(
tween: Tween(
begin: 0,
end: downloadProgress.progress ?? 0,
),
duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator(
value: downloadProgress.progress != null
? value.toDouble()
: null,
),
),
),
errorWidget: noErrorWidget
? null
: (context, url, error) {
return Container(
color: Theme.of(context).colorScheme.surface,
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimateWidgetExtensions(Icon(Symbols.close, size: 24))
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
Text(
error.toString(),
textAlign: TextAlign.center,
),
],
).center(),
);
},
);
}
return Image.network(
url,
return Image(
image: ResizeImage(
UniversalImage.provider(url),
width: resizeWidth?.round(),
height: resizeHeight?.round(),
policy: ResizeImagePolicy.fit,
),
width: width,
height: height,
fit: fit,
cacheHeight: cacheHeight != null
? (cacheHeight! * devicePixelRatio).round()
: null,
cacheWidth:
cacheWidth != null ? (cacheWidth! * devicePixelRatio).round() : null,
loadingBuilder: noProgressIndicator
? null
: (BuildContext context, Widget child,
@@ -146,3 +100,37 @@ class UniversalImage extends StatelessWidget {
return NetworkImage(url);
}
}
class AutoResizeUniversalImage extends StatelessWidget {
final String url;
final double? width, height;
final BoxFit? fit;
final bool noProgressIndicator;
final bool noErrorWidget;
const AutoResizeUniversalImage(
this.url, {
super.key,
this.width,
this.height,
this.fit,
this.noProgressIndicator = false,
this.noErrorWidget = false,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
return UniversalImage(
url,
fit: fit,
width: width,
height: height,
noProgressIndicator: noProgressIndicator,
noErrorWidget: noErrorWidget,
cacheHeight: constraints.maxHeight,
cacheWidth: constraints.maxWidth,
);
});
}
}

View File

@@ -7,16 +7,16 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage/flutter_secure_storage_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStoragePlugin");
flutter_secure_storage_plugin_register_with_registrar(flutter_secure_storage_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View File

@@ -4,7 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
flutter_secure_storage
flutter_secure_storage_linux
url_launcher_linux
)

View File

@@ -7,6 +7,7 @@ import Foundation
import connectivity_plus
import file_selector_macos
import flutter_secure_storage_macos
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
@@ -15,6 +16,7 @@ import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))

View File

@@ -418,10 +418,10 @@ packages:
dependency: transitive
description:
name: file_selector_linux
sha256: "712ce7fab537ba532c8febdb1a8f167b32441e74acd68c3ccb2e36dcb52c4ab2"
sha256: b2b91daf8a68ecfa4a01b778a6f52edef9b14ecd506e771488ea0f2e0784198b
url: "https://pub.dev"
source: hosted
version: "0.9.3"
version: "0.9.3+1"
file_selector_macos:
dependency: transitive
description:
@@ -532,10 +532,50 @@ packages:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9f3dd2ac3b6875b0fde5b04734789c3ef35ba3965c18e99dd564a7a2f8056df6"
sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0"
url: "https://pub.dev"
source: hosted
version: "4.2.1"
version: "9.2.2"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: "4d91bfc23047422cbcd73ac684bc169859ee766482517c22172c86596bf1464b"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "1693ab11121a5f925bbea0be725abfcfbbcf36c1e29e571f84a0c0f436147a81"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_shaders:
dependency: transitive
description:
@@ -598,10 +638,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: ce89c5a993ca5eea74535f798478502c30a625ecb10a1de4d7fef5cd1bcac2a4
sha256: "8ae664a70174163b9f65ea68dd8673e29db8f9095de7b5cd00e167c621f4fef5"
url: "https://pub.dev"
source: hosted
version: "14.4.1"
version: "14.6.0"
google_fonts:
dependency: "direct main"
description:
@@ -766,10 +806,10 @@ packages:
dependency: transitive
description:
name: js
sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.7.1"
version: "0.6.7"
json_annotation:
dependency: "direct main"
description:
@@ -1347,10 +1387,10 @@ packages:
dependency: transitive
description:
name: url_launcher_linux
sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
version: "3.2.1"
url_launcher_macos:
dependency: transitive
description:
@@ -1440,7 +1480,7 @@ packages:
source: hosted
version: "0.1.6"
web_socket_channel:
dependency: transitive
dependency: "direct main"
description:
name: web_socket_channel
sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f"

View File

@@ -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: 1.0.0+1
version: 2.0.0+4
environment:
sdk: ^3.5.4
@@ -58,7 +58,7 @@ dependencies:
google_fonts: ^6.2.1
path: ^1.9.0
relative_time: ^5.0.0
flutter_secure_storage: ^4.2.1
flutter_secure_storage: ^9.2.2
image_picker: ^1.1.2
cross_file: ^0.3.4+2
file_picker: ^8.1.3
@@ -73,6 +73,7 @@ dependencies:
path_provider: ^2.1.5
collection: ^1.18.0
mime: ^2.0.0
web_socket_channel: ^3.0.1
dev_dependencies:
flutter_test:
@@ -166,4 +167,4 @@ flutter_native_splash:
color_dark: "#000000"
branding: "assets/icon/branding-light.png"
branding_dark: "assets/icon/branding-dark.png"
branding_bottom_padding: 24
branding_bottom_padding: 24

9
roadsign.toml Normal file
View File

@@ -0,0 +1,9 @@
id = "solian-next"
[[locations]]
id = "solian-next"
host = ["sn-next.solsynth.dev"]
path = ["/"]
[[locations.destinations]]
id = "solian-next-web"
uri = "files:///workdir/solian-next?fallback=index.html&index=index.html"

View File

@@ -8,6 +8,7 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
@@ -15,6 +16,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
file_selector_windows
flutter_secure_storage_windows
url_launcher_windows
)