Compare commits

...

2 Commits

Author SHA1 Message Date
d4cbabeb31 Better DM 2024-05-29 00:14:41 +08:00
c50a49f37d Realms creation 2024-05-28 22:13:23 +08:00
21 changed files with 733 additions and 250 deletions

View File

@@ -15,13 +15,13 @@ class Account {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.deletedAt,
required this.name,
required this.nick,
required this.avatar,
required this.banner,
required this.description,
this.emailAddress,
required this.emailAddress,
this.externalId,
});

View File

@@ -15,8 +15,8 @@ class Call {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
this.endedAt,
required this.deletedAt,
required this.endedAt,
required this.externalId,
required this.founderId,
required this.channelId,

View File

@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/realm.dart';
@@ -12,6 +10,7 @@ class Channel {
String name;
String description;
int type;
List<ChannelMember>? members;
Account account;
int accountId;
Realm? realm;
@@ -24,16 +23,17 @@ class Channel {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.deletedAt,
required this.alias,
required this.name,
required this.description,
required this.type,
required this.members,
required this.account,
required this.accountId,
required this.isEncrypted,
this.realm,
this.realmId,
required this.realm,
required this.realmId,
});
factory Channel.fromJson(Map<String, dynamic> json) => Channel(
@@ -45,6 +45,10 @@ class Channel {
name: json['name'],
description: json['description'],
type: json['type'],
members: json['members']
?.map((e) => ChannelMember.fromJson(e))
.toList()
.cast<ChannelMember>(),
account: Account.fromJson(json['account']),
accountId: json['account_id'],
realm: json['realm'] != null ? Realm.fromJson(json['realm']) : null,
@@ -61,21 +65,13 @@ class Channel {
'name': name,
'description': description,
'type': type,
'members': members?.map((e) => e.toJson()).toList(),
'account': account.toJson(),
'account_id': accountId,
'realm': realm?.toJson(),
'realm_id': realmId,
'is_encrypted': isEncrypted,
};
IconData get icon {
switch (type) {
case 1:
return FontAwesomeIcons.userGroup;
default:
return FontAwesomeIcons.hashtag;
}
}
}
class ChannelMember {

View File

@@ -16,10 +16,10 @@ class Friendship {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.deletedAt,
required this.accountId,
required this.relatedId,
this.blockedBy,
required this.blockedBy,
required this.account,
required this.related,
required this.status,

View File

@@ -16,25 +16,31 @@ class Notification {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.deletedAt,
required this.subject,
required this.content,
this.links,
required this.links,
required this.isImportant,
required this.isRealtime,
this.readAt,
this.senderId,
required this.readAt,
required this.senderId,
required this.recipientId,
});
factory Notification.fromJson(Map<String, dynamic> json) => Notification(
id: json['id'] ?? 0,
createdAt: json['created_at'] == null ? DateTime.now() : DateTime.parse(json['created_at']),
updatedAt: json['updated_at'] == null ? DateTime.now() : DateTime.parse(json['updated_at']),
createdAt: json['created_at'] == null
? DateTime.now()
: DateTime.parse(json['created_at']),
updatedAt: json['updated_at'] == null
? DateTime.now()
: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
subject: json['subject'],
content: json['content'],
links: json['links'] != null ? List<Link>.from(json['links'].map((x) => Link.fromJson(x))) : List.empty(),
links: json['links'] != null
? List<Link>.from(json['links'].map((x) => Link.fromJson(x)))
: List.empty(),
isImportant: json['is_important'],
isRealtime: json['is_realtime'],
readAt: json['read_at'],
@@ -49,7 +55,9 @@ class Notification {
'deleted_at': deletedAt,
'subject': subject,
'content': content,
'links': links != null ? List<dynamic>.from(links!.map((x) => x.toJson())) : List.empty(),
'links': links != null
? List<dynamic>.from(links!.map((x) => x.toJson()))
: List.empty(),
'is_important': isImportant,
'is_realtime': isRealtime,
'read_at': readAt,

View File

@@ -13,11 +13,11 @@ class PersonalPage {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.deletedAt,
required this.content,
required this.script,
required this.style,
this.links,
required this.links,
required this.accountId,
});
@@ -25,7 +25,9 @@ class PersonalPage {
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null ? DateTime.parse(json['deleted_at']) : null,
deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at'])
: null,
content: json['content'],
script: json['script'],
style: json['style'],

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/account/friend_select.dart';
import 'package:uuid/uuid.dart';
class ChannelProvider extends GetxController {
Future<Response> getChannel(String alias, {String realm = 'global'}) async {
@@ -33,4 +36,70 @@ class ChannelProvider extends GetxController {
return resp;
}
Future<Response> createChannel(String scope, dynamic payload) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) throw Exception('unauthorized');
final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
final resp = await client.post('/api/channels/$scope', payload);
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return resp;
}
Future<Response?> createDirectChannel(
BuildContext context, String scope) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) throw Exception('unauthorized');
final related = await showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => FriendSelect(
title: 'channelOrganizeDirectHint'.tr,
),
);
if (related == null) return null;
final prof = await auth.getProfile();
final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
final resp = await client.post('/api/channels/$scope/dm', {
'alias': const Uuid().v4().replaceAll('-', '').substring(0, 12),
'name': 'DM',
'description':
'A direct message channel between @${prof.body['name']} and @${related.name}',
'related_user': related.id,
'is_encrypted': false,
});
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return resp;
}
Future<Response> updateChannel(String scope, int id, dynamic payload) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) throw Exception('unauthorized');
final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
final resp = await client.put('/api/channels/$scope/$id', payload);
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return resp;
}
}

View File

@@ -11,7 +11,7 @@ class RealmProvider extends GetxController {
client.httpClient.baseUrl = ServiceFinder.services['passport'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
final resp = await client.get('/realms/me/available');
final resp = await client.get('/api/realms/me/available');
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}

View File

@@ -8,6 +8,8 @@ import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/screens/channel/channel_organize.dart';
import 'package:solian/screens/contact.dart';
import 'package:solian/screens/posts/post_detail.dart';
import 'package:solian/screens/realms.dart';
import 'package:solian/screens/realms/realm_organize.dart';
import 'package:solian/screens/social.dart';
import 'package:solian/screens/posts/post_publish.dart';
import 'package:solian/shells/basic_shell.dart';
@@ -18,7 +20,7 @@ abstract class AppRouter {
routes: [
ShellRoute(
builder: (context, state, child) =>
NavShell(state: state, child: child, showAppBar: false),
NavShell(state: state, showAppBar: false, child: child),
routes: [
GoRoute(
path: '/',
@@ -30,6 +32,11 @@ abstract class AppRouter {
name: 'contact',
builder: (context, state) => const ContactScreen(),
),
GoRoute(
path: '/realms',
name: 'realms',
builder: (context, state) => const RealmListScreen(),
),
GoRoute(
path: '/account',
name: 'account',
@@ -114,6 +121,16 @@ abstract class AppRouter {
);
},
),
GoRoute(
path: '/realm/organize',
name: 'realmOrganizing',
builder: (context, state) {
final arguments = state.extra as RealmOrganizeArguments?;
return RealmOrganizeScreen(
edit: arguments?.edit,
);
},
),
],
);
}

View File

@@ -96,11 +96,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
CircleAvatar(
const CircleAvatar(
radius: 28,
backgroundColor: Colors.teal,
child: FaIcon(
widget.channel.icon,
FontAwesomeIcons.hashtag,
color: Colors.white,
size: 18,
),

View File

@@ -1,14 +1,12 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/router.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/account/friend_select.dart';
import 'package:solian/widgets/prev_page.dart';
import 'package:uuid/uuid.dart';
@@ -29,11 +27,6 @@ class ChannelOrganizeScreen extends StatefulWidget {
}
class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
static Map<int, String> channelTypes = {
0: 'channelTypeCommon'.tr,
1: 'channelTypeDirect'.tr,
};
bool _isBusy = false;
final _aliasController = TextEditingController();
@@ -41,38 +34,6 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
final _descriptionController = TextEditingController();
bool _isEncrypted = false;
int _channelType = 0;
List<Account> _initialMembers = List.empty(growable: true);
void selectInitialMembers() async {
final input = await showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => FriendSelect(
title: 'channelMember'.tr,
trailingBuilder: (item) {
if (_initialMembers.any((e) => e.id == item.id)) {
return const Icon(Icons.check);
} else {
return null;
}
},
),
);
if (input == null) return;
setState(() {
if (_initialMembers.any((e) => e.id == input.id)) {
_initialMembers = _initialMembers
.where((e) => e.id != input.id)
.toList(growable: true);
} else {
_initialMembers.add(input as Account);
}
});
}
void applyChannel() async {
final AuthProvider auth = Get.find();
@@ -82,6 +43,8 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
setState(() => _isBusy = true);
final ChannelProvider provider = Get.find();
final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
@@ -92,27 +55,21 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
'name': _nameController.value.text,
'description': _descriptionController.value.text,
'is_encrypted': _isEncrypted,
if (_channelType == 1)
'members': _initialMembers.map((e) => e.id).toList(),
};
Response resp;
if (widget.edit != null) {
resp = await client.put(
'/api/channels/$scope/${widget.edit!.id}',
payload,
);
} else if (_channelType == 1) {
resp = await client.post('/api/channels/$scope/dm', payload);
} else {
resp = await client.post('/api/channels/$scope', payload);
}
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString);
} else {
AppRouter.instance.pop(resp.body);
Response? resp;
try {
if (widget.edit != null) {
resp = await provider.updateChannel(scope!, widget.edit!.id, payload);
} else {
resp = await provider.createChannel(scope!, payload);
}
} catch (e) {
context.showErrorDialog(e);
}
AppRouter.instance.pop(resp!.body);
setState(() => _isBusy = false);
}
@@ -127,7 +84,6 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
_nameController.text = widget.edit!.name;
_descriptionController.text = widget.edit!.description;
_isEncrypted = widget.edit!.isEncrypted;
_channelType = widget.edit!.type;
}
}
@@ -227,55 +183,6 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
).paddingSymmetric(horizontal: 16, vertical: 12),
),
const Divider(thickness: 0.3),
if (_channelType == 1 && widget.edit == null)
ListTile(
leading: const Icon(Icons.supervisor_account)
.paddingSymmetric(horizontal: 8),
title: Text('channelMember'.tr),
subtitle: _initialMembers.isNotEmpty
? Text(_initialMembers.map((e) => e.name).join(' '))
: null,
trailing: const Icon(Icons.chevron_right),
onTap: () => selectInitialMembers(),
).animate().fadeIn().slideY(
begin: 1,
end: 0,
curve: Curves.fastEaseInToSlowEaseOut,
),
ListTile(
leading: const Icon(Icons.mode).paddingSymmetric(horizontal: 8),
title: Text('channelType'.tr),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<int>(
isExpanded: true,
items: channelTypes.entries
.map((item) => DropdownMenuItem<int>(
enabled: widget.edit == null ||
item.key == widget.edit?.type,
value: item.key,
child: Text(
item.value,
style: const TextStyle(
fontSize: 14,
),
),
))
.toList(),
value: _channelType,
onChanged: (int? value) {
setState(() => _channelType = value ?? 0);
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(left: 16, right: 1),
height: 40,
width: 140,
),
menuItemStyleData: const MenuItemStyleData(
height: 40,
),
),
),
),
CheckboxListTile(
title: Text('channelEncrypted'.tr),
value: _isEncrypted,

View File

@@ -8,6 +8,7 @@ import 'package:solian/providers/content/channel.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/signin_required_overlay.dart';
class ContactScreen extends StatefulWidget {
@@ -19,9 +20,16 @@ class ContactScreen extends StatefulWidget {
class _ContactScreenState extends State<ContactScreen> {
bool _isBusy = true;
int? _accountId;
final List<Channel> _channels = List.empty(growable: true);
getProfile() async {
final AuthProvider auth = Get.find();
final prof = await auth.getProfile();
_accountId = prof.body['id'];
}
getChannels() async {
setState(() => _isBusy = true);
@@ -42,6 +50,7 @@ class _ContactScreenState extends State<ContactScreen> {
void initState() {
super.initState();
getProfile();
getChannels();
}
@@ -52,105 +61,166 @@ class _ContactScreenState extends State<ContactScreen> {
return Material(
color: Theme.of(context).colorScheme.surface,
child: FutureBuilder(
future: auth.isAuthorized,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
} else if (snapshot.data == false) {
return SigninRequiredOverlay(
onSignedIn: () {
getChannels();
},
);
}
future: auth.isAuthorized,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
} else if (snapshot.data == false) {
return SigninRequiredOverlay(
onSignedIn: () {
getChannels();
},
);
}
return SafeArea(
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
context),
sliver: SliverAppBar(
title: Text('contact'.tr),
centerTitle: false,
titleSpacing:
SolianTheme.isLargeScreen(context) ? null : 24,
forceElevated: innerBoxIsScrolled,
actions: [
const NotificationButton(),
IconButton(
icon: const Icon(Icons.add_circle),
onPressed: () {
AppRouter.instance
.pushNamed('channelOrganizing')
.then(
(value) {
if (value != null) {
getChannels();
}
},
);
},
),
SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
),
];
},
body: MediaQuery.removePadding(
removeTop: true,
context: context,
child: Column(
children: [
if (_isBusy)
const LinearProgressIndicator().animate().scaleX(),
Expanded(
child: RefreshIndicator(
onRefresh: () => getChannels(),
child: ListView.builder(
itemCount: _channels.length,
itemBuilder: (context, index) {
final element = _channels[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.indigo,
child: FaIcon(
element.icon,
color: Colors.white,
size: 16,
),
return SafeArea(
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
context),
sliver: SliverAppBar(
title: Text('contact'.tr),
centerTitle: false,
titleSpacing:
SolianTheme.isLargeScreen(context) ? null : 24,
forceElevated: innerBoxIsScrolled,
actions: [
const NotificationButton(),
PopupMenuButton(
icon: const Icon(Icons.add_circle),
itemBuilder: (BuildContext context) => [
PopupMenuItem(
child: ListTile(
title: Text('channelOrganizeCommon'.tr),
leading: const Icon(Icons.tag),
contentPadding:
const EdgeInsets.symmetric(horizontal: 8),
),
onTap: () {
AppRouter.instance
.pushNamed('channelOrganizing')
.then(
(value) {
if (value != null) getChannels();
},
);
},
),
PopupMenuItem(
child: ListTile(
title: Text('channelOrganizeDirect'.tr),
leading: const FaIcon(
FontAwesomeIcons.userGroup,
size: 16,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
title: Text(element.name),
subtitle: Text(element.description),
onTap: () {
AppRouter.instance.pushNamed(
'channelChat',
pathParameters: {'alias': element.alias},
queryParameters: {
if (element.realmId != null)
'realm': element.realm!.alias,
},
);
},
);
},
),
const EdgeInsets.symmetric(horizontal: 8),
),
onTap: () {
final ChannelProvider provider = Get.find();
provider
.createDirectChannel(context, 'global')
.then((resp) {
if (resp != null) {
getChannels();
}
});
},
),
],
),
SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
),
];
},
body: MediaQuery.removePadding(
removeTop: true,
context: context,
child: Column(
children: [
if (_isBusy)
const LinearProgressIndicator().animate().scaleX(),
Expanded(
child: RefreshIndicator(
onRefresh: () => getChannels(),
child: ListView.builder(
itemCount: _channels.length,
itemBuilder: (context, index) {
final element = _channels[index];
return buildItem(element);
},
),
),
],
),
),
],
),
),
);
}),
),
);
},
),
);
}
Widget buildItem(Channel element) {
if (element.type == 1) {
final otherside = element.members!
.where((e) => e.account.externalId != _accountId)
.first;
return ListTile(
leading: AccountAvatar(
content: otherside.account.avatar,
bgColor: Colors.indigo,
feColor: Colors.white,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(otherside.account.name),
subtitle: Text(
'channelDirectDescription'
.trParams({'username': otherside.account.name}),
),
onTap: () {
AppRouter.instance.pushNamed(
'channelChat',
pathParameters: {'alias': element.alias},
queryParameters: {
if (element.realmId != null) 'realm': element.realm!.alias,
},
);
},
);
}
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.indigo,
child: FaIcon(
FontAwesomeIcons.hashtag,
color: Colors.white,
size: 16,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(element.name),
subtitle: Text(element.description),
onTap: () {
AppRouter.instance.pushNamed(
'channelChat',
pathParameters: {'alias': element.alias},
queryParameters: {
if (element.realmId != null) 'realm': element.realm!.alias,
},
);
},
);
}
}

View File

@@ -1,10 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/signin_required_overlay.dart';
class RealmListScreen extends StatelessWidget {
class RealmListScreen extends StatefulWidget {
const RealmListScreen({super.key});
@override
State<RealmListScreen> createState() => _RealmListScreenState();
}
class _RealmListScreenState extends State<RealmListScreen> {
bool _isBusy = true;
final List<Realm> _realms = List.empty(growable: true);
getRealms() async {
setState(() => _isBusy = true);
final RealmProvider provider = Get.find();
final resp = await provider.listAvailableRealm();
setState(() {
_realms.clear();
_realms.addAll(
resp.body.map((e) => Realm.fromJson(e)).toList().cast<Realm>(),
);
});
setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
getRealms();
}
@override
Widget build(BuildContext context) {
throw UnimplementedError();
final AuthProvider auth = Get.find();
return Material(
color: Theme.of(context).colorScheme.surface,
child: FutureBuilder(
future: auth.isAuthorized,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
} else if (snapshot.data == false) {
return SigninRequiredOverlay(
onSignedIn: () {
getRealms();
},
);
}
return SafeArea(
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
context),
sliver: SliverAppBar(
title: Text('realm'.tr),
centerTitle: false,
titleSpacing:
SolianTheme.isLargeScreen(context) ? null : 24,
forceElevated: innerBoxIsScrolled,
actions: [
const NotificationButton(),
IconButton(
icon: const Icon(Icons.add_circle),
onPressed: () {
AppRouter.instance
.pushNamed('realmOrganizing')
.then(
(value) {
if (value != null) getRealms();
},
);
},
),
SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
),
];
},
body: MediaQuery.removePadding(
removeTop: true,
context: context,
child: Column(
children: [
if (_isBusy)
const LinearProgressIndicator().animate().scaleX(),
Expanded(
child: RefreshIndicator(
onRefresh: () => getRealms(),
child: ListView.builder(
itemCount: _realms.length,
itemBuilder: (context, index) {
final element = _realms[index];
return buildRealm(element);
},
),
),
),
],
),
),
),
);
},
),
);
}
Widget buildRealm(Realm element) {
return Card(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 7,
child: Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
),
const Positioned(
bottom: -30,
left: 18,
child: CircleAvatar(
radius: 24,
backgroundColor: Colors.indigo,
child: FaIcon(
FontAwesomeIcons.globe,
color: Colors.white,
size: 18,
),
),
),
],
),
).paddingOnly(bottom: 20),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(element.name),
subtitle: Text(
element.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
)
],
),
onTap: () {},
),
),
).paddingOnly(left: 8, right: 8, bottom: 4);
}
}

View File

@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/prev_page.dart';
import 'package:uuid/uuid.dart';
class RealmOrganizeArguments {
final Realm? edit;
RealmOrganizeArguments({this.edit});
}
class RealmOrganizeScreen extends StatefulWidget {
final Realm? edit;
const RealmOrganizeScreen({super.key, this.edit});
@override
State<RealmOrganizeScreen> createState() => _RealmOrganizeScreenState();
}
class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
bool _isBusy = false;
final _aliasController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isCommunity = false;
bool _isPublic = false;
void applyRealm() async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return;
if (_aliasController.value.text.isEmpty) randomizeAlias();
setState(() => _isBusy = true);
final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['passport'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
final payload = {
'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text,
'description': _descriptionController.value.text,
'is_public': _isPublic,
'is_community': _isCommunity,
};
Response resp;
if (widget.edit != null) {
resp = await client.put('/api/realms/${widget.edit!.id}', payload);
} else {
resp = await client.post('/api/realms', payload);
}
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString);
} else {
AppRouter.instance.pop(resp.body);
}
setState(() => _isBusy = false);
}
void randomizeAlias() {
_aliasController.text =
const Uuid().v4().replaceAll('-', '').substring(0, 12);
}
void syncWidget() {
if (widget.edit != null) {
_aliasController.text = widget.edit!.alias;
_nameController.text = widget.edit!.name;
_descriptionController.text = widget.edit!.description;
_isPublic = widget.edit!.isPublic;
_isCommunity = widget.edit!.isCommunity;
}
}
void cancelAction() {
AppRouter.instance.pop();
}
@override
void initState() {
syncWidget();
super.initState();
}
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
appBar: AppBar(
title: Text('realmOrganizing'.tr),
leading: const PrevPageButton(),
actions: [
TextButton(
onPressed: _isBusy ? null : () => applyRealm(),
child: Text('apply'.tr.toUpperCase()),
)
],
),
body: SafeArea(
top: false,
child: Column(
children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
if (widget.edit != null)
MaterialBanner(
leading: const Icon(Icons.edit),
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent,
content: Text(
'realmEditingNotify'
.trParams({'realm': '#${widget.edit!.alias}'}),
),
actions: [
TextButton(
onPressed: cancelAction,
child: Text('cancel'.tr),
),
],
),
Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _aliasController,
decoration: InputDecoration.collapsed(
hintText: 'realmAlias'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: () => randomizeAlias(),
child: const Icon(Icons.refresh),
)
],
).paddingSymmetric(horizontal: 16, vertical: 2),
const Divider(thickness: 0.3),
TextField(
autocorrect: true,
controller: _nameController,
decoration: InputDecoration.collapsed(
hintText: 'realmName'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).paddingSymmetric(horizontal: 16, vertical: 8),
const Divider(thickness: 0.3),
Expanded(
child: TextField(
minLines: 5,
maxLines: null,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _descriptionController,
decoration: InputDecoration.collapsed(
hintText: 'realmDescription'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).paddingSymmetric(horizontal: 16, vertical: 12),
),
const Divider(thickness: 0.3),
CheckboxListTile(
title: Text('realmPublic'.tr),
value: _isPublic,
onChanged: (newValue) =>
setState(() => _isPublic = newValue ?? false),
controlAffinity: ListTileControlAffinity.leading,
),
CheckboxListTile(
title: Text('realmCommunity'.tr),
value: _isCommunity,
onChanged: (newValue) =>
setState(() => _isCommunity = newValue ?? false),
controlAffinity: ListTileControlAffinity.leading,
),
],
),
),
),
);
}
}

View File

@@ -96,11 +96,23 @@ class SolianMessages extends Translations {
'attachmentAddFile': 'Attach file',
'attachmentSetting': 'Adjust attachment',
'attachmentAlt': 'Alternative text',
'realm': 'Realm',
'realms': 'Realms',
'realmOrganizing': 'Organize a realm',
'realmAlias': 'Alias (Identifier)',
'realmName': 'Name',
'realmDescription': 'Description',
'realmPublic': 'Public Realm',
'realmCommunity': 'Community Realm',
'channelOrganizing': 'Organize a channel',
'channelOrganizeCommon': 'Create regular channel',
'channelOrganizeDirect': 'Create DM',
'channelOrganizeDirectHint': 'Choose friend to create DM',
'channelEditingNotify': 'You\'re editing channel @channel',
'channelAlias': 'Alias (Identifier)',
'channelName': 'Name',
'channelDescription': 'Description',
'channelDirectDescription': 'Direct message with @username',
'channelEncrypted': 'Encrypted Channel',
'channelMember': 'Channel member',
'channelMembers': 'Channel members',
@@ -205,11 +217,23 @@ class SolianMessages extends Translations {
'attachmentAddFile': '附加文件',
'attachmentSetting': '调整附件',
'attachmentAlt': '替代文字',
'realm': '领域',
'realms': '领域',
'realmOrganizing': '组织领域',
'realmAlias': '别称(标识符)',
'realmName': '显示名称',
'realmDescription': '领域简介',
'realmPublic': '公开领域',
'realmCommunity': '社区领域',
'channelOrganizing': '组织频道',
'channelOrganizeCommon': '创建普通频道',
'channelOrganizeDirect': '创建私信频道',
'channelOrganizeDirectHint': '选择好友来创建私信',
'channelEditingNotify': '你正在编辑频道 @channel',
'channelAlias': '别称(标识符)',
'channelName': '显示名称',
'channelDescription': '频道简介',
'channelDirectDescription': '与 @username 的私聊',
'channelEncrypted': '加密频道',
'channelMember': '频道成员',
'channelMembers': '频道成员',

View File

@@ -3,11 +3,17 @@ import 'package:solian/services.dart';
class AccountAvatar extends StatelessWidget {
final dynamic content;
final Color? color;
final Color? bgColor;
final Color? feColor;
final double? radius;
const AccountAvatar(
{super.key, required this.content, this.color, this.radius});
const AccountAvatar({
super.key,
required this.content,
this.bgColor,
this.feColor,
this.radius,
});
@override
Widget build(BuildContext context) {
@@ -22,7 +28,7 @@ class AccountAvatar extends StatelessWidget {
return CircleAvatar(
key: Key('a$content'),
radius: radius,
backgroundColor: color,
backgroundColor: bgColor,
backgroundImage: !isEmpty
? NetworkImage(
direct
@@ -34,6 +40,7 @@ class AccountAvatar extends StatelessWidget {
? Icon(
Icons.account_circle,
size: radius != null ? radius! * 1.2 : 24,
color: feColor,
)
: null,
);

View File

@@ -19,7 +19,7 @@ class FriendSelect extends StatefulWidget {
class _FriendSelectState extends State<FriendSelect> {
int _accountId = 0;
List<Friendship> _friends = List.empty(growable: true);
final List<Friendship> _friends = List.empty(growable: true);
getFriends() async {
final AuthProvider auth = Get.find();

View File

@@ -56,7 +56,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
ratio: await calculateFileAspectRatio(file),
);
} catch (err) {
this.context.showErrorDialog(err);
context.showErrorDialog(err);
}
}
@@ -79,7 +79,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
try {
await uploadAttachment(file, hash, ratio: ratio);
} catch (err) {
this.context.showErrorDialog(err);
context.showErrorDialog(err);
}
setState(() => _isBusy = false);
@@ -102,7 +102,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
try {
await uploadAttachment(file, hash);
} catch (err) {
this.context.showErrorDialog(err);
context.showErrorDialog(err);
}
}
@@ -136,7 +136,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
try {
await uploadAttachment(file, hash, ratio: ratio);
} catch (err) {
this.context.showErrorDialog(err);
context.showErrorDialog(err);
}
setState(() => _isBusy = false);
@@ -392,7 +392,7 @@ class _AttachmentEditingDialogState extends State<AttachmentEditingDialog> {
);
return Attachment.fromJson(resp.body);
} catch (e) {
this.context.showErrorDialog(e);
context.showErrorDialog(e);
return null;
} finally {
setState(() => _isBusy = false);
@@ -406,7 +406,7 @@ class _AttachmentEditingDialogState extends State<AttachmentEditingDialog> {
await provider.deleteAttachment(widget.item.id);
widget.onDelete();
} catch (e) {
this.context.showErrorDialog(e);
context.showErrorDialog(e);
} finally {
setState(() => _isBusy = false);
}

View File

@@ -13,6 +13,11 @@ abstract class AppNavigation {
label: 'contact'.tr,
page: 'contact',
),
AppNavigationDestination(
icon: const Icon(Icons.workspaces),
label: 'realms'.tr,
page: 'realms',
),
AppNavigationDestination(
icon: const Icon(Icons.account_circle),
label: 'account'.tr,
@@ -26,6 +31,9 @@ class AppNavigationDestination {
final String label;
final String page;
AppNavigationDestination(
{required this.icon, required this.label, required this.page});
AppNavigationDestination({
required this.icon,
required this.label,
required this.page,
});
}

View File

@@ -23,6 +23,7 @@ class _AppNavigationBottomBarState extends State<AppNavigationBottomBar> {
),
)
.toList(),
type: BottomNavigationBarType.fixed,
landscapeLayout: BottomNavigationBarLandscapeLayout.centered,
currentIndex: _selectedIndex,
showUnselectedLabels: false,

View File

@@ -1,4 +1,3 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';