✨ Notification UI
This commit is contained in:
parent
9d6cf54bf8
commit
9e609b8fe4
@ -152,7 +152,7 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"statusActivityTitle": "{} is {} {}",
|
"statusActivityTitle": "{} is {} {}",
|
||||||
"statusActivityEndedTitle": "{} is {} {} until {}",
|
"statusActivityEndedTitle": "{} is {} {} until {}",
|
||||||
"appSettings": "App Settings",
|
"appSettings": "App settings",
|
||||||
"accountSettings": "Account Settings",
|
"accountSettings": "Account Settings",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
@ -258,5 +258,7 @@
|
|||||||
"walletNotFound": "Wallet not found",
|
"walletNotFound": "Wallet not found",
|
||||||
"walletCreateHint": "You don't have a wallet yet. Create one to start using the Solar Network eWallet.",
|
"walletCreateHint": "You don't have a wallet yet. Create one to start using the Solar Network eWallet.",
|
||||||
"walletCreate": "Create a Wallet",
|
"walletCreate": "Create a Wallet",
|
||||||
"settingsServerUrl": "Server URL"
|
"settingsServerUrl": "Server URL",
|
||||||
|
"settingsApplied": "The settings has been applied.",
|
||||||
|
"notifications": "Notifications"
|
||||||
}
|
}
|
||||||
|
@ -86,3 +86,24 @@ abstract class SnAccountBadge with _$SnAccountBadge {
|
|||||||
factory SnAccountBadge.fromJson(Map<String, dynamic> json) =>
|
factory SnAccountBadge.fromJson(Map<String, dynamic> json) =>
|
||||||
_$SnAccountBadgeFromJson(json);
|
_$SnAccountBadgeFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class SnNotification with _$SnNotification {
|
||||||
|
const factory SnNotification({
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
required DateTime? deletedAt,
|
||||||
|
required String id,
|
||||||
|
required String topic,
|
||||||
|
required String title,
|
||||||
|
@Default('') String subtitle,
|
||||||
|
required String content,
|
||||||
|
@Default({}) Map<String, dynamic> meta,
|
||||||
|
required int priority,
|
||||||
|
required DateTime? viewedAt,
|
||||||
|
required String accountId,
|
||||||
|
}) = _SnNotification;
|
||||||
|
|
||||||
|
factory SnNotification.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnNotificationFromJson(json);
|
||||||
|
}
|
||||||
|
@ -749,6 +749,178 @@ as DateTime?,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$SnNotification {
|
||||||
|
|
||||||
|
DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; String get id; String get topic; String get title; String get subtitle; String get content; Map<String, dynamic> get meta; int get priority; DateTime? get viewedAt; String get accountId;
|
||||||
|
/// Create a copy of SnNotification
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnNotificationCopyWith<SnNotification> get copyWith => _$SnNotificationCopyWithImpl<SnNotification>(this as SnNotification, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this SnNotification to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnNotification&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.topic, topic) || other.topic == topic)&&(identical(other.title, title) || other.title == title)&&(identical(other.subtitle, subtitle) || other.subtitle == subtitle)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other.meta, meta)&&(identical(other.priority, priority) || other.priority == priority)&&(identical(other.viewedAt, viewedAt) || other.viewedAt == viewedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,topic,title,subtitle,content,const DeepCollectionEquality().hash(meta),priority,viewedAt,accountId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SnNotification(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, topic: $topic, title: $title, subtitle: $subtitle, content: $content, meta: $meta, priority: $priority, viewedAt: $viewedAt, accountId: $accountId)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $SnNotificationCopyWith<$Res> {
|
||||||
|
factory $SnNotificationCopyWith(SnNotification value, $Res Function(SnNotification) _then) = _$SnNotificationCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String topic, String title, String subtitle, String content, Map<String, dynamic> meta, int priority, DateTime? viewedAt, String accountId
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$SnNotificationCopyWithImpl<$Res>
|
||||||
|
implements $SnNotificationCopyWith<$Res> {
|
||||||
|
_$SnNotificationCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final SnNotification _self;
|
||||||
|
final $Res Function(SnNotification) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SnNotification
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? topic = null,Object? title = null,Object? subtitle = null,Object? content = null,Object? meta = null,Object? priority = null,Object? viewedAt = freezed,Object? accountId = null,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,topic: null == topic ? _self.topic : topic // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,subtitle: null == subtitle ? _self.subtitle : subtitle // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,meta: null == meta ? _self.meta : meta // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Map<String, dynamic>,priority: null == priority ? _self.priority : priority // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,viewedAt: freezed == viewedAt ? _self.viewedAt : viewedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _SnNotification implements SnNotification {
|
||||||
|
const _SnNotification({required this.createdAt, required this.updatedAt, required this.deletedAt, required this.id, required this.topic, required this.title, this.subtitle = '', required this.content, final Map<String, dynamic> meta = const {}, required this.priority, required this.viewedAt, required this.accountId}): _meta = meta;
|
||||||
|
factory _SnNotification.fromJson(Map<String, dynamic> json) => _$SnNotificationFromJson(json);
|
||||||
|
|
||||||
|
@override final DateTime createdAt;
|
||||||
|
@override final DateTime updatedAt;
|
||||||
|
@override final DateTime? deletedAt;
|
||||||
|
@override final String id;
|
||||||
|
@override final String topic;
|
||||||
|
@override final String title;
|
||||||
|
@override@JsonKey() final String subtitle;
|
||||||
|
@override final String content;
|
||||||
|
final Map<String, dynamic> _meta;
|
||||||
|
@override@JsonKey() Map<String, dynamic> get meta {
|
||||||
|
if (_meta is EqualUnmodifiableMapView) return _meta;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableMapView(_meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override final int priority;
|
||||||
|
@override final DateTime? viewedAt;
|
||||||
|
@override final String accountId;
|
||||||
|
|
||||||
|
/// Create a copy of SnNotification
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$SnNotificationCopyWith<_SnNotification> get copyWith => __$SnNotificationCopyWithImpl<_SnNotification>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$SnNotificationToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnNotification&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.id, id) || other.id == id)&&(identical(other.topic, topic) || other.topic == topic)&&(identical(other.title, title) || other.title == title)&&(identical(other.subtitle, subtitle) || other.subtitle == subtitle)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other._meta, _meta)&&(identical(other.priority, priority) || other.priority == priority)&&(identical(other.viewedAt, viewedAt) || other.viewedAt == viewedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,id,topic,title,subtitle,content,const DeepCollectionEquality().hash(_meta),priority,viewedAt,accountId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SnNotification(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, id: $id, topic: $topic, title: $title, subtitle: $subtitle, content: $content, meta: $meta, priority: $priority, viewedAt: $viewedAt, accountId: $accountId)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$SnNotificationCopyWith<$Res> implements $SnNotificationCopyWith<$Res> {
|
||||||
|
factory _$SnNotificationCopyWith(_SnNotification value, $Res Function(_SnNotification) _then) = __$SnNotificationCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, String id, String topic, String title, String subtitle, String content, Map<String, dynamic> meta, int priority, DateTime? viewedAt, String accountId
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$SnNotificationCopyWithImpl<$Res>
|
||||||
|
implements _$SnNotificationCopyWith<$Res> {
|
||||||
|
__$SnNotificationCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _SnNotification _self;
|
||||||
|
final $Res Function(_SnNotification) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SnNotification
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? id = null,Object? topic = null,Object? title = null,Object? subtitle = null,Object? content = null,Object? meta = null,Object? priority = null,Object? viewedAt = freezed,Object? accountId = null,}) {
|
||||||
|
return _then(_SnNotification(
|
||||||
|
createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,topic: null == topic ? _self.topic : topic // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,subtitle: null == subtitle ? _self.subtitle : subtitle // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,meta: null == meta ? _self._meta : meta // ignore: cast_nullable_to_non_nullable
|
||||||
|
as Map<String, dynamic>,priority: null == priority ? _self.priority : priority // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,viewedAt: freezed == viewedAt ? _self.viewedAt : viewedAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// dart format on
|
// dart format on
|
||||||
|
@ -160,3 +160,41 @@ Map<String, dynamic> _$SnAccountBadgeToJson(_SnAccountBadge instance) =>
|
|||||||
'updated_at': instance.updatedAt.toIso8601String(),
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_SnNotification _$SnNotificationFromJson(Map<String, dynamic> json) =>
|
||||||
|
_SnNotification(
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
deletedAt:
|
||||||
|
json['deleted_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['deleted_at'] as String),
|
||||||
|
id: json['id'] as String,
|
||||||
|
topic: json['topic'] as String,
|
||||||
|
title: json['title'] as String,
|
||||||
|
subtitle: json['subtitle'] as String? ?? '',
|
||||||
|
content: json['content'] as String,
|
||||||
|
meta: json['meta'] as Map<String, dynamic>? ?? const {},
|
||||||
|
priority: (json['priority'] as num).toInt(),
|
||||||
|
viewedAt:
|
||||||
|
json['viewed_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['viewed_at'] as String),
|
||||||
|
accountId: json['account_id'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnNotificationToJson(_SnNotification instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
|
'id': instance.id,
|
||||||
|
'topic': instance.topic,
|
||||||
|
'title': instance.title,
|
||||||
|
'subtitle': instance.subtitle,
|
||||||
|
'content': instance.content,
|
||||||
|
'meta': instance.meta,
|
||||||
|
'priority': instance.priority,
|
||||||
|
'viewed_at': instance.viewedAt?.toIso8601String(),
|
||||||
|
'account_id': instance.accountId,
|
||||||
|
};
|
||||||
|
@ -38,36 +38,31 @@ final websocketProvider = Provider<WebSocketService>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
class WebSocketService {
|
class WebSocketService {
|
||||||
|
late Ref _ref;
|
||||||
WebSocketChannel? _channel;
|
WebSocketChannel? _channel;
|
||||||
final StreamController<WebSocketPacket> _streamController =
|
final StreamController<WebSocketPacket> _streamController =
|
||||||
StreamController<WebSocketPacket>.broadcast();
|
StreamController<WebSocketPacket>.broadcast();
|
||||||
final StreamController<WebSocketState> _statusStreamController =
|
final StreamController<WebSocketState> _statusStreamController =
|
||||||
StreamController<WebSocketState>.broadcast();
|
StreamController<WebSocketState>.broadcast();
|
||||||
String? _lastUrl;
|
|
||||||
String? _lastAtk;
|
|
||||||
Timer? _reconnectTimer;
|
Timer? _reconnectTimer;
|
||||||
|
|
||||||
Stream<WebSocketPacket> get dataStream => _streamController.stream;
|
Stream<WebSocketPacket> get dataStream => _streamController.stream;
|
||||||
Stream<WebSocketState> get statusStream => _statusStreamController.stream;
|
Stream<WebSocketState> get statusStream => _statusStreamController.stream;
|
||||||
|
|
||||||
Future<void> connect(String url, String atk, {Ref? ref}) async {
|
Future<void> connect(Ref ref) async {
|
||||||
_lastUrl = url;
|
_ref = ref;
|
||||||
_lastAtk = atk;
|
|
||||||
|
|
||||||
if (ref != null) {
|
final baseUrl = ref.watch(serverUrlProvider);
|
||||||
final freshAtk = await getFreshAtk(
|
final atk = await getFreshAtk(
|
||||||
ref.watch(tokenPairProvider),
|
ref.watch(tokenPairProvider),
|
||||||
url.replaceFirst('ws', 'http').replaceFirst('/ws', ''),
|
baseUrl,
|
||||||
onRefreshed: (atk, rtk) {
|
onRefreshed: (atk, rtk) {
|
||||||
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
|
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
|
||||||
ref.invalidate(tokenPairProvider);
|
ref.invalidate(tokenPairProvider);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (freshAtk != null) {
|
|
||||||
atk = freshAtk;
|
final url = '$baseUrl/ws'.replaceFirst('http', 'ws');
|
||||||
_lastAtk = freshAtk;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log('[WebSocket] Trying connecting to $url');
|
log('[WebSocket] Trying connecting to $url');
|
||||||
try {
|
try {
|
||||||
@ -113,10 +108,8 @@ class WebSocketService {
|
|||||||
void _scheduleReconnect() {
|
void _scheduleReconnect() {
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
_reconnectTimer = Timer(const Duration(milliseconds: 500), () {
|
_reconnectTimer = Timer(const Duration(milliseconds: 500), () {
|
||||||
if (_lastUrl != null && _lastAtk != null) {
|
_statusStreamController.sink.add(WebSocketState.connecting());
|
||||||
_statusStreamController.sink.add(WebSocketState.connecting());
|
connect(_ref);
|
||||||
connect(_lastUrl!, _lastAtk!);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,8 +121,6 @@ class WebSocketService {
|
|||||||
|
|
||||||
void close() {
|
void close() {
|
||||||
_reconnectTimer?.cancel();
|
_reconnectTimer?.cancel();
|
||||||
_lastUrl = null;
|
|
||||||
_lastAtk = null;
|
|
||||||
_channel?.sink.close();
|
_channel?.sink.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -162,11 +153,7 @@ class WebSocketStateNotifier extends StateNotifier<WebSocketState> {
|
|||||||
state = const WebSocketState.error('Unauthorized');
|
state = const WebSocketState.error('Unauthorized');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await service.connect(
|
await service.connect(ref);
|
||||||
'$baseUrl/ws'.replaceFirst('http', 'ws'),
|
|
||||||
atk,
|
|
||||||
ref: ref,
|
|
||||||
);
|
|
||||||
state = const WebSocketState.connected();
|
state = const WebSocketState.connected();
|
||||||
service.statusStream.listen((event) {
|
service.statusStream.listen((event) {
|
||||||
state = event;
|
state = event;
|
||||||
|
@ -14,15 +14,15 @@ class AppRouter extends RootStackRouter {
|
|||||||
page: AccountShellRoute.page,
|
page: AccountShellRoute.page,
|
||||||
path: '/account',
|
path: '/account',
|
||||||
children: [
|
children: [
|
||||||
|
AutoRoute(page: AccountRoute.page, path: ''),
|
||||||
|
AutoRoute(page: NotificationRoute.page, path: 'notifications'),
|
||||||
AutoRoute(page: WalletRoute.page, path: 'wallet'),
|
AutoRoute(page: WalletRoute.page, path: 'wallet'),
|
||||||
AutoRoute(page: RelationshipRoute.page, path: 'relationships'),
|
AutoRoute(page: RelationshipRoute.page, path: 'relationships'),
|
||||||
AutoRoute(page: AccountRoute.page, path: ''),
|
|
||||||
AutoRoute(page: AccountSettingsRoute.page, path: 'settings'),
|
|
||||||
AutoRoute(page: AccountProfileRoute.page, path: ':name'),
|
AutoRoute(page: AccountProfileRoute.page, path: ':name'),
|
||||||
AutoRoute(page: PublisherProfileRoute.page, path: ':name/calendar'),
|
AutoRoute(page: PublisherProfileRoute.page, path: ':name/calendar'),
|
||||||
AutoRoute(page: MyselfEventCalendarRoute.page, path: 'me/calendar'),
|
AutoRoute(page: MyselfEventCalendarRoute.page, path: 'me/calendar'),
|
||||||
AutoRoute(page: UpdateProfileRoute.page, path: 'me/update'),
|
AutoRoute(page: UpdateProfileRoute.page, path: 'me/update'),
|
||||||
AutoRoute(page: ManagedPublisherRoute.page, path: 'me/publishers'),
|
AutoRoute(page: AccountSettingsRoute.page, path: 'settings'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
AutoRoute(page: RealmListRoute.page, path: '/realms'),
|
AutoRoute(page: RealmListRoute.page, path: '/realms'),
|
||||||
@ -37,10 +37,9 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: ChatDetailRoute.page, path: ':id/detail'),
|
AutoRoute(page: ChatDetailRoute.page, path: ':id/detail'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
AutoRoute(page: SettingsRoute.page, path: '/settings'),
|
|
||||||
AutoRoute(page: LoginRoute.page, path: '/auth/login'),
|
AutoRoute(page: LoginRoute.page, path: '/auth/login'),
|
||||||
AutoRoute(page: CreateAccountRoute.page, path: '/auth/create-account'),
|
AutoRoute(page: CreateAccountRoute.page, path: '/auth/create-account'),
|
||||||
AutoRoute(page: AccountSettingsRoute.page, path: '/account/settings'),
|
AutoRoute(page: SettingsRoute.page, path: '/settings'),
|
||||||
AutoRoute(page: PostComposeRoute.page, path: '/posts/compose'),
|
AutoRoute(page: PostComposeRoute.page, path: '/posts/compose'),
|
||||||
AutoRoute(page: PostDetailRoute.page, path: '/posts/:id'),
|
AutoRoute(page: PostDetailRoute.page, path: '/posts/:id'),
|
||||||
AutoRoute(page: PostEditRoute.page, path: '/posts/:id/edit'),
|
AutoRoute(page: PostEditRoute.page, path: '/posts/:id/edit'),
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -9,6 +9,7 @@ import 'package:island/pods/message.dart';
|
|||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
import 'package:island/pods/userinfo.dart';
|
import 'package:island/pods/userinfo.dart';
|
||||||
import 'package:island/route.gr.dart';
|
import 'package:island/route.gr.dart';
|
||||||
|
import 'package:island/screens/notification.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
import 'package:island/widgets/account/status.dart';
|
import 'package:island/widgets/account/status.dart';
|
||||||
import 'package:island/widgets/account/leveling_progress.dart';
|
import 'package:island/widgets/account/leveling_progress.dart';
|
||||||
@ -52,6 +53,9 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final user = ref.watch(userInfoProvider);
|
final user = ref.watch(userInfoProvider);
|
||||||
|
final notificationUnreadCount = ref.watch(
|
||||||
|
notificationUnreadCountNotifierProvider,
|
||||||
|
);
|
||||||
|
|
||||||
if (!user.hasValue || user.value == null) {
|
if (!user.hasValue || user.value == null) {
|
||||||
return _UnauthorizedAccountScreen();
|
return _UnauthorizedAccountScreen();
|
||||||
@ -168,12 +172,20 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
const Gap(8),
|
const Gap(8),
|
||||||
ListTile(
|
ListTile(
|
||||||
minTileHeight: 48,
|
minTileHeight: 48,
|
||||||
leading: const Icon(Symbols.public),
|
leading: const Icon(Symbols.notifications),
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||||
title: Text('publishers').tr(),
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text('notifications').tr()),
|
||||||
|
Badge.count(
|
||||||
|
count: notificationUnreadCount.value ?? 0,
|
||||||
|
isLabelVisible: (notificationUnreadCount.value ?? 0) > 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.router.push(ManagedPublisherRoute());
|
context.router.push(NotificationRoute());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
|
@ -5,6 +5,7 @@ import 'package:gap/gap.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/route.dart';
|
import 'package:island/route.dart';
|
||||||
import 'package:island/route.gr.dart';
|
import 'package:island/route.gr.dart';
|
||||||
|
import 'package:island/screens/notification.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
@ -17,7 +18,6 @@ class TabNavigationObserver extends AutoRouterObserver {
|
|||||||
@override
|
@override
|
||||||
void didPush(Route route, Route? previousRoute) {
|
void didPush(Route route, Route? previousRoute) {
|
||||||
Future(() {
|
Future(() {
|
||||||
print('didPush: ${route.settings.name}');
|
|
||||||
onChange(route.settings.name);
|
onChange(route.settings.name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -25,7 +25,6 @@ class TabNavigationObserver extends AutoRouterObserver {
|
|||||||
@override
|
@override
|
||||||
void didPop(Route route, Route? previousRoute) {
|
void didPop(Route route, Route? previousRoute) {
|
||||||
Future(() {
|
Future(() {
|
||||||
print('didPop: ${previousRoute?.settings.name}');
|
|
||||||
onChange(previousRoute?.settings.name);
|
onChange(previousRoute?.settings.name);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -46,7 +45,10 @@ class TabsNavigationWidget extends HookConsumerWidget {
|
|||||||
final useHorizontalLayout = isWideScreen(context);
|
final useHorizontalLayout = isWideScreen(context);
|
||||||
final useExpandableLayout = isWidestScreen(context);
|
final useExpandableLayout = isWidestScreen(context);
|
||||||
final currentRoute = ref.watch(currentRouteProvider);
|
final currentRoute = ref.watch(currentRouteProvider);
|
||||||
print('currentRoute: $currentRoute');
|
|
||||||
|
final notificationUnreadCount = ref.watch(
|
||||||
|
notificationUnreadCountNotifierProvider,
|
||||||
|
);
|
||||||
|
|
||||||
int activeIndex = 0;
|
int activeIndex = 0;
|
||||||
|
|
||||||
@ -62,7 +64,11 @@ class TabsNavigationWidget extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
label: 'account'.tr(),
|
label: 'account'.tr(),
|
||||||
icon: const Icon(Symbols.account_circle),
|
icon: Badge.count(
|
||||||
|
count: notificationUnreadCount.value ?? 0,
|
||||||
|
isLabelVisible: (notificationUnreadCount.value ?? 0) > 0,
|
||||||
|
child: const Icon(Symbols.account_circle),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
194
lib/screens/notification.dart
Normal file
194
lib/screens/notification.dart
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/models/user.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/widgets/alert.dart';
|
||||||
|
import 'package:island/widgets/content/markdown.dart';
|
||||||
|
import 'package:relative_time/relative_time.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
part 'notification.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class NotificationUnreadCountNotifier
|
||||||
|
extends _$NotificationUnreadCountNotifier {
|
||||||
|
@override
|
||||||
|
Future<int> build() async {
|
||||||
|
try {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final response = await client.get('/notifications/count');
|
||||||
|
return (response.data as num).toInt();
|
||||||
|
} catch (_) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> decrement(int count) async {
|
||||||
|
final current = await future;
|
||||||
|
state = AsyncData(math.min(current - count, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class NotificationListNotifier extends _$NotificationListNotifier
|
||||||
|
with CursorPagingNotifierMixin<SnNotification> {
|
||||||
|
static const int _pageSize = 5;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPagingData<SnNotification>> build() => fetch(cursor: null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPagingData<SnNotification>> fetch({
|
||||||
|
required String? cursor,
|
||||||
|
}) async {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final offset = cursor == null ? 0 : int.parse(cursor);
|
||||||
|
|
||||||
|
final queryParams = {'offset': offset, 'take': _pageSize};
|
||||||
|
|
||||||
|
final response = await client.get(
|
||||||
|
'/notifications',
|
||||||
|
queryParameters: queryParams,
|
||||||
|
);
|
||||||
|
final total = int.parse(response.headers.value('X-Total') ?? '0');
|
||||||
|
final List<dynamic> data = response.data;
|
||||||
|
final notifications =
|
||||||
|
data.map((json) => SnNotification.fromJson(json)).toList();
|
||||||
|
|
||||||
|
final hasMore = offset + notifications.length < total;
|
||||||
|
final nextCursor =
|
||||||
|
hasMore ? (offset + notifications.length).toString() : null;
|
||||||
|
final unreadCount = notifications.where((n) => n.viewedAt == null).length;
|
||||||
|
ref
|
||||||
|
.read(notificationUnreadCountNotifierProvider.notifier)
|
||||||
|
.decrement(unreadCount);
|
||||||
|
|
||||||
|
return CursorPagingData(
|
||||||
|
items: notifications,
|
||||||
|
hasMore: hasMore,
|
||||||
|
nextCursor: nextCursor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class NotificationScreen extends HookConsumerWidget {
|
||||||
|
const NotificationScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('notifications').tr()),
|
||||||
|
body: PagingHelperView(
|
||||||
|
provider: notificationListNotifierProvider,
|
||||||
|
futureRefreshable: notificationListNotifierProvider.future,
|
||||||
|
notifierRefreshable: notificationListNotifierProvider.notifier,
|
||||||
|
contentBuilder:
|
||||||
|
(data, widgetCount, endItemView) => ListView.builder(
|
||||||
|
itemCount: widgetCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == widgetCount - 1) {
|
||||||
|
return endItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
final notification = data.items[index];
|
||||||
|
return ListTile(
|
||||||
|
isThreeLine: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
title: Text(notification.title),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (notification.subtitle.isNotEmpty)
|
||||||
|
Text(notification.subtitle).bold(),
|
||||||
|
Row(
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
DateFormat().format(
|
||||||
|
notification.createdAt.toLocal(),
|
||||||
|
),
|
||||||
|
).fontSize(11),
|
||||||
|
Text('·').fontSize(11).bold(),
|
||||||
|
Text(
|
||||||
|
RelativeTime(
|
||||||
|
context,
|
||||||
|
).format(notification.createdAt.toLocal()),
|
||||||
|
).fontSize(11),
|
||||||
|
],
|
||||||
|
).opacity(0.75).padding(bottom: 4),
|
||||||
|
MarkdownTextContent(
|
||||||
|
content: notification.content,
|
||||||
|
textStyle: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withOpacity(0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing:
|
||||||
|
notification.viewedAt == null
|
||||||
|
? null
|
||||||
|
: Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.blue,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
if (notification.meta['link'] is String) {
|
||||||
|
final href = notification.meta['link'];
|
||||||
|
final uri = Uri.tryParse(href);
|
||||||
|
if (uri == null) {
|
||||||
|
showSnackBar(
|
||||||
|
context,
|
||||||
|
'brokenLink'.tr(args: []),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'copyToClipboard'.tr(),
|
||||||
|
onPressed: () {
|
||||||
|
Clipboard.setData(ClipboardData(text: href));
|
||||||
|
clearSnackBar(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (uri.scheme == 'solian') {
|
||||||
|
context.router.pushPath(
|
||||||
|
['', uri.host, ...uri.pathSegments].join('/'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showConfirmAlert(
|
||||||
|
'openLinkConfirmDescription'.tr(args: [href]),
|
||||||
|
'openLinkConfirm'.tr(),
|
||||||
|
).then((value) {
|
||||||
|
if (value) {
|
||||||
|
launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
52
lib/screens/notification.g.dart
Normal file
52
lib/screens/notification.g.dart
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'notification.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$notificationUnreadCountNotifierHash() =>
|
||||||
|
r'ddec25e8e693b8feb800c085ef87d65f0d172341';
|
||||||
|
|
||||||
|
/// See also [NotificationUnreadCountNotifier].
|
||||||
|
@ProviderFor(NotificationUnreadCountNotifier)
|
||||||
|
final notificationUnreadCountNotifierProvider =
|
||||||
|
AutoDisposeAsyncNotifierProvider<
|
||||||
|
NotificationUnreadCountNotifier,
|
||||||
|
int
|
||||||
|
>.internal(
|
||||||
|
NotificationUnreadCountNotifier.new,
|
||||||
|
name: r'notificationUnreadCountNotifierProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$notificationUnreadCountNotifierHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef _$NotificationUnreadCountNotifier = AutoDisposeAsyncNotifier<int>;
|
||||||
|
String _$notificationListNotifierHash() =>
|
||||||
|
r'934a47bc2ce9e75699a4f53e2169470fd0c04a53';
|
||||||
|
|
||||||
|
/// See also [NotificationListNotifier].
|
||||||
|
@ProviderFor(NotificationListNotifier)
|
||||||
|
final notificationListNotifierProvider = AutoDisposeAsyncNotifierProvider<
|
||||||
|
NotificationListNotifier,
|
||||||
|
CursorPagingData<SnNotification>
|
||||||
|
>.internal(
|
||||||
|
NotificationListNotifier.new,
|
||||||
|
name: r'notificationListNotifierProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$notificationListNotifierHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef _$NotificationListNotifier =
|
||||||
|
AutoDisposeAsyncNotifier<CursorPagingData<SnNotification>>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
@ -4,6 +4,7 @@ import 'package:dropdown_button2/dropdown_button2.dart';
|
|||||||
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:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.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';
|
||||||
@ -87,6 +88,7 @@ class SettingsScreen extends HookConsumerWidget {
|
|||||||
kNetworkServerDefault,
|
kNetworkServerDefault,
|
||||||
);
|
);
|
||||||
ref.invalidate(serverUrlProvider);
|
ref.invalidate(serverUrlProvider);
|
||||||
|
showSnackBar(context, 'settingsApplied'.tr());
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
@ -98,6 +100,7 @@ class SettingsScreen extends HookConsumerWidget {
|
|||||||
if (value.isNotEmpty) {
|
if (value.isNotEmpty) {
|
||||||
prefs.setString(kNetworkServerStoreKey, value);
|
prefs.setString(kNetworkServerStoreKey, value);
|
||||||
ref.invalidate(serverUrlProvider);
|
ref.invalidate(serverUrlProvider);
|
||||||
|
showSnackBar(context, 'settingsApplied'.tr());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -71,8 +71,8 @@ class MarkdownTextContent extends HookConsumerWidget {
|
|||||||
style:
|
style:
|
||||||
linkStyle ??
|
linkStyle ??
|
||||||
TextStyle(color: Theme.of(context).colorScheme.primary),
|
TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||||
onTap: (herf) {
|
onTap: (href) {
|
||||||
final url = Uri.tryParse(herf);
|
final url = Uri.tryParse(href);
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
if (url.scheme == 'solian') {
|
if (url.scheme == 'solian') {
|
||||||
context.router.pushPath(
|
context.router.pushPath(
|
||||||
@ -96,11 +96,11 @@ class MarkdownTextContent extends HookConsumerWidget {
|
|||||||
} else {
|
} else {
|
||||||
showSnackBar(
|
showSnackBar(
|
||||||
context,
|
context,
|
||||||
'brokenLink'.tr(args: [herf]),
|
'brokenLink'.tr(args: [href]),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: 'copyToClipboard'.tr(),
|
label: 'copyToClipboard'.tr(),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Clipboard.setData(ClipboardData(text: herf));
|
Clipboard.setData(ClipboardData(text: href));
|
||||||
clearSnackBar(context);
|
clearSnackBar(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user