Compare commits

...

9 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
35 changed files with 954 additions and 319 deletions

View File

@ -9,6 +9,13 @@
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml 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: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml` # section below to disable rules from the `package:flutter_lints/flutter.yaml`

View File

@ -131,5 +131,7 @@
"sensitiveContent": "Sensitive Content", "sensitiveContent": "Sensitive Content",
"sensitiveContentCollapsed": "Sensitive content has been collapsed.", "sensitiveContentCollapsed": "Sensitive content has been collapsed.",
"sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.", "sensitiveContentDescription": "This content has been marked as sensitive, and may not be suitable for all viewers.",
"sensitiveContentReveal": "Reveal" "sensitiveContentReveal": "Reveal",
"serverConnecting": "Connecting to server...",
"serverDisconnected": "Lost connection from server"
} }

View File

@ -131,5 +131,7 @@
"sensitiveContent": "敏感内容", "sensitiveContent": "敏感内容",
"sensitiveContentCollapsed": "敏感内容已折叠。", "sensitiveContentCollapsed": "敏感内容已折叠。",
"sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。", "sensitiveContentDescription": "此内容已被标记,可能不适合所有人查看。",
"sensitiveContentReveal": "显示内容" "sensitiveContentReveal": "显示内容",
"serverConnecting": "正在连接服务器…",
"serverDisconnected": "已与服务器断开连接"
} }

View File

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

View File

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

View File

@ -44,48 +44,11 @@ class SnNetworkProvider {
RequestOptions options, RequestOptions options,
RequestInterceptorHandler handler, RequestInterceptorHandler handler,
) async { ) async {
try { final atk = await getFreshAtk();
var atk = await _storage.read(key: kAtkStoreKey); if (atk != null) {
if (atk != null) { options.headers['Authorization'] = 'Bearer $atk';
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);
} }
return handler.next(options);
}, },
), ),
); );
@ -99,6 +62,50 @@ 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) { String getAttachmentUrl(String ky) {
if (ky.startsWith("http")) return ky; if (ky.startsWith("http")) return ky;
return '${client.options.baseUrl}/cgi/uc/attachments/$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 SnNetworkProvider _sn;
late final FlutterSecureStorage _storage = FlutterSecureStorage(); late final FlutterSecureStorage _storage = FlutterSecureStorage();
Future<String?> get atk => _storage.read(key: kAtkStoreKey);
UserProvider(BuildContext context) { UserProvider(BuildContext context) {
_sn = context.read<SnNetworkProvider>(); _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

@ -16,126 +16,124 @@ import 'package:surface/screens/settings.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/navigation/app_scaffold.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( final appRouter = GoRouter(
routes: [ routes: [
ShellRoute( ShellRoute(
builder: (context, state, child) => AppScaffold( builder: (context, state, child) => AppRootScaffold(body: child),
body: child, routes: _appRoutes,
showBottomNavigation: true,
showDrawer: 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(),
),
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) => 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,
showDrawer: 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(),
),
],
), ),
], ],
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,10 +9,10 @@ import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.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_comment_list.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_mini_editor.dart'; import 'package:surface/widgets/post/post_mini_editor.dart';
@ -72,9 +72,10 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return AppScaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BackButton( leading: BackButton(
onPressed: () { onPressed: () {
@ -108,10 +109,13 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
), ),
if (_data != null) if (_data != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: PostItem( child: Container(
data: _data!, constraints: const BoxConstraints(maxWidth: 640),
showComments: false, child: PostItem(
), data: _data!,
showComments: false,
),
).center(),
), ),
const SliverToBoxAdapter(child: Divider(height: 1)), const SliverToBoxAdapter(child: Divider(height: 1)),
if (_data != null) if (_data != null)
@ -127,7 +131,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
], ],
).padding(horizontal: 20, vertical: 12), ).padding(horizontal: 20, vertical: 12),
), ),
if (_data != null) if (_data != null && ua.isAuthorized)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: Container(
height: 240, height: 240,
@ -158,6 +162,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
PostCommentSliverList( PostCommentSliverList(
key: _childListKey, key: _childListKey,
parentPostId: _data!.id, parentPostId: _data!.id,
maxWidth: 640,
), ),
SliverGap(math.max(MediaQuery.of(context).padding.bottom, 16)), 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/types/post.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/loading_indicator.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_item.dart';
import 'package:surface/widgets/post/post_media_pending_list.dart'; import 'package:surface/widgets/post/post_media_pending_list.dart';
import 'package:surface/widgets/post/post_meta_editor.dart'; import 'package:surface/widgets/post/post_meta_editor.dart';
@ -111,7 +110,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
return ListenableBuilder( return ListenableBuilder(
listenable: _writeController, listenable: _writeController,
builder: (context, _) { builder: (context, _) {
return AppScaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: BackButton( leading: BackButton(
onPressed: () { onPressed: () {

View File

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

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

@ -1,5 +1,3 @@
import 'dart:math' as math;
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
@ -11,14 +9,15 @@ class AttachmentList extends StatelessWidget {
final List<SnAttachment> data; final List<SnAttachment> data;
final bool? bordered; final bool? bordered;
final double? maxHeight; final double? maxHeight;
final EdgeInsets? listPadding;
const AttachmentList({ const AttachmentList({
super.key, super.key,
required this.data, required this.data,
this.bordered, this.bordered,
this.maxHeight, this.maxHeight,
this.listPadding,
}); });
static const double kMaxItemWidth = 520;
static const BorderRadius kDefaultRadius = static const BorderRadius kDefaultRadius =
BorderRadius.all(Radius.circular(8)); BorderRadius.all(Radius.circular(8));
@ -27,27 +26,31 @@ class AttachmentList extends StatelessWidget {
final borderSide = (bordered ?? false) final borderSide = (bordered ?? false)
? BorderSide(width: 1, color: Theme.of(context).dividerColor) ? BorderSide(width: 1, color: Theme.of(context).dividerColor)
: BorderSide.none; : 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.isEmpty) return const SizedBox.shrink();
if (data.length == 1) { if (data.length == 1) {
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) { if (ResponsiveBreakpoints.of(context).largerThan(MOBILE)) {
return Container( return Padding(
constraints: BoxConstraints( // Single child list-like displaying
maxHeight: maxHeight ?? double.infinity, padding: listPadding ?? EdgeInsets.zero,
maxWidth: math.min( child: Container(
MediaQuery.of(context).size.width - 20, constraints: constraints,
kMaxItemWidth, decoration: BoxDecoration(
), color: backgroundColor,
), border: Border(top: borderSide, bottom: borderSide),
decoration: BoxDecoration(
border: Border(top: borderSide, bottom: borderSide),
borderRadius: kDefaultRadius,
),
child: AspectRatio(
aspectRatio: data[0].metadata['ratio']?.toDouble() ?? 1,
child: ClipRRect(
borderRadius: kDefaultRadius, 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),
),
), ),
), ),
); );
@ -55,6 +58,7 @@ class AttachmentList extends StatelessWidget {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor,
border: Border(top: borderSide, bottom: borderSide), border: Border(top: borderSide, bottom: borderSide),
), ),
child: AspectRatio( child: AspectRatio(
@ -72,29 +76,36 @@ class AttachmentList extends StatelessWidget {
shrinkWrap: true, shrinkWrap: true,
itemCount: data.length, itemCount: data.length,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return Container( return Stack(
constraints: BoxConstraints( children: [
maxHeight: maxHeight ?? double.infinity, Container(
maxWidth: math.min( constraints: constraints,
MediaQuery.of(context).size.width - 20, decoration: BoxDecoration(
kMaxItemWidth, 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),
),
),
), ),
), Positioned(
decoration: BoxDecoration( right: 12,
border: Border(top: borderSide, bottom: borderSide), bottom: 12,
borderRadius: kDefaultRadius, child: Chip(
), label: Text('${idx + 1}/${data.length}'),
child: AspectRatio( ),
aspectRatio: data[idx].metadata['ratio']?.toDouble() ?? 1,
child: ClipRRect(
borderRadius: kDefaultRadius,
child: AttachmentItem(data: data[idx], isExpandable: true),
), ),
), ],
); );
}, },
separatorBuilder: (context, index) => const Gap(8), separatorBuilder: (context, index) => const Gap(8),
padding: const EdgeInsets.symmetric(horizontal: 12), padding: listPadding,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
scrollDirection: Axis.horizontal, 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,5 +1,3 @@
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@ -9,7 +7,8 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/navigation.dart'; import 'package:surface/providers/navigation.dart';
class AppNavigationDrawer extends StatefulWidget { class AppNavigationDrawer extends StatefulWidget {
const AppNavigationDrawer({super.key}); final double? elevation;
const AppNavigationDrawer({super.key, this.elevation});
@override @override
State<AppNavigationDrawer> createState() => _AppNavigationDrawerState(); State<AppNavigationDrawer> createState() => _AppNavigationDrawerState();
@ -43,6 +42,7 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
]; ];
return NavigationDrawer( return NavigationDrawer(
elevation: widget.elevation,
backgroundColor: backgroundColor, backgroundColor: backgroundColor,
selectedIndex: nav.currentIndex, selectedIndex: nav.currentIndex,
children: [ children: [
@ -51,12 +51,12 @@ class _AppNavigationDrawerState extends State<AppNavigationDrawer> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Solar Network').bold(), Text('Solar Network').bold(),
Text('Solar Network 2.0α').fontSize(12).textColor( Text('Canary Preview 2.0α').fontSize(12).textColor(
Theme.of(context).colorScheme.onSurface.withOpacity(0.5)), Theme.of(context).colorScheme.onSurface.withOpacity(0.5)),
], ],
).padding( ).padding(
horizontal: 32, horizontal: 32,
top: math.max(MediaQuery.of(context).padding.top, 16), top: MediaQuery.of(context).padding.top > 16 ? 8 : 16,
bottom: 16, bottom: 16,
), ),
...destinations.where((ele) => ele.isPinned).map((ele) { ...destinations.where((ele) => ele.isPinned).map((ele) {

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,76 +2,93 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:responsive_framework/responsive_framework.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/dialog.dart';
import 'package:surface/widgets/navigation/app_background.dart'; import 'package:surface/widgets/navigation/app_background.dart';
import 'package:surface/widgets/navigation/app_bottom_navigation.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_drawer_navigation.dart';
import 'package:surface/widgets/navigation/app_rail_navigation.dart';
class AppScaffold extends StatelessWidget { class AppScaffold extends StatelessWidget {
final PreferredSizeWidget? appBar;
final FloatingActionButtonLocation? floatingActionButtonLocation;
final Widget? floatingActionButton;
final String? title; final String? title;
final Widget? body; final Widget? body;
final bool autoImplyAppBar; final bool showAppBar;
final bool showBottomNavigation; final bool showBottomNavigation;
final bool showDrawer;
const AppScaffold({ const AppScaffold({
super.key, super.key,
this.appBar,
this.floatingActionButton,
this.floatingActionButtonLocation,
this.title, this.title,
this.body, this.body,
this.autoImplyAppBar = false, this.showAppBar = true,
this.showBottomNavigation = false, this.showBottomNavigation = false,
this.showDrawer = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isShowDrawer = showDrawer
? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
: false;
final isShowBottomNavigation = (showBottomNavigation) final isShowBottomNavigation = (showBottomNavigation)
? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE) ? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
: false; : false;
final state = GoRouter.maybeOf(context); final state = GoRouter.maybeOf(context);
final autoTitle = state != null
? 'screen${state.routerDelegate.currentConfiguration.last.route.name?.capitalize()}'
: 'screen';
final innerWidget = AppBackground( return Scaffold(
child: Scaffold( appBar: showAppBar
appBar: appBar ?? ? AppBar(
(autoImplyAppBar title: Text(title ?? autoTitle.tr()),
? AppBar( )
title: title != null : null,
? Text(title!) body: body,
: state != null bottomNavigationBar:
? Text( isShowBottomNavigation ? AppBottomNavigationBar() : null,
('screen${state.routerDelegate.currentConfiguration.last.route.name?.capitalize()}') );
.tr(), }
) }
: null)
: null), class AppRootScaffold extends StatelessWidget {
body: body, final Widget body;
floatingActionButtonLocation: floatingActionButtonLocation, const AppRootScaffold({super.key, required this.body});
floatingActionButton: floatingActionButton,
drawer: isShowDrawer ? AppNavigationDrawer() : null, @override
bottomNavigationBar: Widget build(BuildContext context) {
isShowBottomNavigation ? AppBottomNavigationBar() : null, 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(
body: Column(
children: [
ConnectionIndicator(),
Expanded(child: innerWidget),
],
),
drawer: !isExpandDrawer ? AppNavigationDrawer() : null,
),
); );
if (showDrawer) {
return Row(
children: [
AppNavigationDrawer(),
VerticalDivider(width: 1, color: Theme.of(context).dividerColor),
Expanded(child: innerWidget),
],
);
}
return innerWidget;
} }
} }

View File

@ -7,6 +7,7 @@ import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_attachment.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/post/post_mini_editor.dart'; import 'package:surface/widgets/post/post_mini_editor.dart';
@ -14,7 +15,12 @@ import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PostCommentSliverList extends StatefulWidget { class PostCommentSliverList extends StatefulWidget {
final int parentPostId; final int parentPostId;
const PostCommentSliverList({super.key, required this.parentPostId}); final double? maxWidth;
const PostCommentSliverList({
super.key,
required this.parentPostId,
this.maxWidth,
});
@override @override
State<PostCommentSliverList> createState() => PostCommentSliverListState(); State<PostCommentSliverList> createState() => PostCommentSliverListState();
@ -88,7 +94,12 @@ class PostCommentSliverListState extends State<PostCommentSliverList> {
onFetchData: _fetchPosts, onFetchData: _fetchPosts,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
return GestureDetector( return GestureDetector(
child: PostItem(data: _posts[idx]), child: Container(
constraints: BoxConstraints(
maxWidth: widget.maxWidth ?? double.infinity,
),
child: PostItem(data: _posts[idx]),
).center(),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed( GoRouter.of(context).pushNamed(
'postDetail', 'postDetail',
@ -121,6 +132,7 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ua = context.watch<UserProvider>();
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
return Column( return Column(
@ -139,25 +151,26 @@ class _PostCommentListPopupState extends State<PostCommentListPopup> {
Expanded( Expanded(
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter( if (ua.isAuthorized)
child: Container( SliverToBoxAdapter(
height: 240, child: Container(
decoration: BoxDecoration( height: 240,
border: Border.symmetric( decoration: BoxDecoration(
horizontal: BorderSide( border: Border.symmetric(
color: Theme.of(context).dividerColor, horizontal: BorderSide(
width: 1 / devicePixelRatio, color: Theme.of(context).dividerColor,
width: 1 / devicePixelRatio,
),
), ),
), ),
), child: PostMiniEditor(
child: PostMiniEditor( postReplyId: widget.postId,
postReplyId: widget.postId, onPost: () {
onPost: () { _childListKey.currentState!.refresh();
_childListKey.currentState!.refresh(); },
}, ),
), ),
), ),
),
PostCommentSliverList( PostCommentSliverList(
key: _childListKey, key: _childListKey,
parentPostId: widget.postId, parentPostId: widget.postId,

View File

@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
@ -34,10 +33,6 @@ class PostItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isListAttachments =
ResponsiveBreakpoints.of(context).largerThan(MOBILE) ||
(data.preload?.attachments?.length ?? 0) > 1;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -52,7 +47,8 @@ class PostItem extends StatelessWidget {
data: data.preload!.attachments!, data: data.preload!.attachments!,
bordered: true, bordered: true,
maxHeight: 520, maxHeight: 520,
).padding(horizontal: isListAttachments ? 12 : 0), listPadding: const EdgeInsets.symmetric(horizontal: 12),
),
_PostBottomAction( _PostBottomAction(
data: data, data: data,
showComments: showComments, showComments: showComments,

View File

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

View File

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

View File

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

View File

@ -418,10 +418,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: file_selector_linux name: file_selector_linux
sha256: "712ce7fab537ba532c8febdb1a8f167b32441e74acd68c3ccb2e36dcb52c4ab2" sha256: b2b91daf8a68ecfa4a01b778a6f52edef9b14ecd506e771488ea0f2e0784198b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.9.3" version: "0.9.3+1"
file_selector_macos: file_selector_macos:
dependency: transitive dependency: transitive
description: description:
@ -532,10 +532,50 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_secure_storage name: flutter_secure_storage
sha256: "9f3dd2ac3b6875b0fde5b04734789c3ef35ba3965c18e99dd564a7a2f8056df6" sha256: "165164745e6afb5c0e3e3fcc72a012fb9e58496fb26ffb92cf22e16a821e85d0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted 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: flutter_shaders:
dependency: transitive dependency: transitive
description: description:
@ -598,10 +638,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: ce89c5a993ca5eea74535f798478502c30a625ecb10a1de4d7fef5cd1bcac2a4 sha256: "8ae664a70174163b9f65ea68dd8673e29db8f9095de7b5cd00e167c621f4fef5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.4.1" version: "14.6.0"
google_fonts: google_fonts:
dependency: "direct main" dependency: "direct main"
description: description:
@ -766,10 +806,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: js name: js
sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.1" version: "0.6.7"
json_annotation: json_annotation:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1347,10 +1387,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_linux name: url_launcher_linux
sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.0" version: "3.2.1"
url_launcher_macos: url_launcher_macos:
dependency: transitive dependency: transitive
description: description:
@ -1440,7 +1480,7 @@ packages:
source: hosted source: hosted
version: "0.1.6" version: "0.1.6"
web_socket_channel: web_socket_channel:
dependency: transitive dependency: "direct main"
description: description:
name: web_socket_channel name: web_socket_channel
sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" 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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 2.0.0+2 version: 2.0.0+4
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@ -58,7 +58,7 @@ dependencies:
google_fonts: ^6.2.1 google_fonts: ^6.2.1
path: ^1.9.0 path: ^1.9.0
relative_time: ^5.0.0 relative_time: ^5.0.0
flutter_secure_storage: ^4.2.1 flutter_secure_storage: ^9.2.2
image_picker: ^1.1.2 image_picker: ^1.1.2
cross_file: ^0.3.4+2 cross_file: ^0.3.4+2
file_picker: ^8.1.3 file_picker: ^8.1.3
@ -73,6 +73,7 @@ dependencies:
path_provider: ^2.1.5 path_provider: ^2.1.5
collection: ^1.18.0 collection: ^1.18.0
mime: ^2.0.0 mime: ^2.0.0
web_socket_channel: ^3.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

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

View File

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