From fb6e65a0fb9fea7e86abdba9c309db6183badba8 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 10 May 2025 12:38:34 +0800 Subject: [PATCH] :sparkles: Status creation & update & clear --- assets/i18n/en-US.json | 23 ++- lib/models/user.dart | 21 ++ lib/models/user.freezed.dart | 166 +++++++++++++++ lib/models/user.g.dart | 38 ++++ lib/pods/websocket.dart | 1 + lib/screens/account.dart | 138 ++++++++----- lib/widgets/account/status.dart | 130 ++++++++++++ lib/widgets/account/status.g.dart | 153 ++++++++++++++ lib/widgets/account/status_creation.dart | 249 +++++++++++++++++++++++ 9 files changed, 868 insertions(+), 51 deletions(-) create mode 100644 lib/widgets/account/status.dart create mode 100644 lib/widgets/account/status.g.dart create mode 100644 lib/widgets/account/status_creation.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 125e488..6f3a92f 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -36,6 +36,7 @@ "editPublisher": "Edit Publisher", "syncPublisher": "Use Account Data", "create": "Create", + "update": "Update", "edit": "Edit", "delete": "Delete", "deletePublisher": "Delete Publisher {}", @@ -117,5 +118,25 @@ "checkInResultLevel4": "Best Luck", "checkInActivityTitle": "{} checked in on {} and got a {}", "eventCalander": "Event Calander", - "eventCalanderEmpty": "No events on that day." + "eventCalanderEmpty": "No events on that day.", + "creatorHub": "Creator Hub", + "creatorHubDescription": "Manage posts, analytics, and more.", + "developerPortal": "Developer Portal", + "developerPortalDescription": "Build with Solar Network™.", + "statusCreateHint": "What's on your mind? Add a status.", + "statusCreate": "Add a Status", + "statusUpdate": "Update Status", + "statusLabel": "Status", + "statusAttitude": "Attitude", + "attitudePositive": "Positive", + "attitudeNeutral": "Neutral", + "attitudeNegative": "Negative", + "statusInvisible": "Invisible", + "statusInvisibleDescription": "Your will be showing as offline to others.", + "statusNotDisturb": "Do Not Disturb", + "statusNotDisturbDescription": "Push notification will be disabled.", + "statusClearTime": "Cleared At", + "statusNoAutoClear": "Do not auto clear", + "online": "Online", + "offline": "Offline" } diff --git a/lib/models/user.dart b/lib/models/user.dart index 5480fb7..2e30b42 100644 --- a/lib/models/user.dart +++ b/lib/models/user.dart @@ -42,3 +42,24 @@ abstract class SnAccountProfile with _$SnAccountProfile { factory SnAccountProfile.fromJson(Map json) => _$SnAccountProfileFromJson(json); } + +@freezed +abstract class SnAccountStatus with _$SnAccountStatus { + const factory SnAccountStatus({ + required String id, + required int attitude, + required bool isOnline, + required bool isInvisible, + required bool isNotDisturb, + required bool isCustomized, + @Default("") String label, + required DateTime? clearedAt, + required int accountId, + required DateTime createdAt, + required DateTime updatedAt, + required DateTime? deletedAt, + }) = _SnAccountStatus; + + factory SnAccountStatus.fromJson(Map json) => + _$SnAccountStatusFromJson(json); +} diff --git a/lib/models/user.freezed.dart b/lib/models/user.freezed.dart index 59b156e..ddf7701 100644 --- a/lib/models/user.freezed.dart +++ b/lib/models/user.freezed.dart @@ -401,4 +401,170 @@ $SnCloudFileCopyWith<$Res>? get background { } } + +/// @nodoc +mixin _$SnAccountStatus { + + String get id; int get attitude; bool get isOnline; bool get isInvisible; bool get isNotDisturb; bool get isCustomized; String get label; DateTime? get clearedAt; int get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; +/// Create a copy of SnAccountStatus +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SnAccountStatusCopyWith get copyWith => _$SnAccountStatusCopyWithImpl(this as SnAccountStatus, _$identity); + + /// Serializes this SnAccountStatus to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAccountStatus&&(identical(other.id, id) || other.id == id)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.isInvisible, isInvisible) || other.isInvisible == isInvisible)&&(identical(other.isNotDisturb, isNotDisturb) || other.isNotDisturb == isNotDisturb)&&(identical(other.isCustomized, isCustomized) || other.isCustomized == isCustomized)&&(identical(other.label, label) || other.label == label)&&(identical(other.clearedAt, clearedAt) || other.clearedAt == clearedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,attitude,isOnline,isInvisible,isNotDisturb,isCustomized,label,clearedAt,accountId,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAccountStatus(id: $id, attitude: $attitude, isOnline: $isOnline, isInvisible: $isInvisible, isNotDisturb: $isNotDisturb, isCustomized: $isCustomized, label: $label, clearedAt: $clearedAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class $SnAccountStatusCopyWith<$Res> { + factory $SnAccountStatusCopyWith(SnAccountStatus value, $Res Function(SnAccountStatus) _then) = _$SnAccountStatusCopyWithImpl; +@useResult +$Res call({ + String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, DateTime? clearedAt, int accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class _$SnAccountStatusCopyWithImpl<$Res> + implements $SnAccountStatusCopyWith<$Res> { + _$SnAccountStatusCopyWithImpl(this._self, this._then); + + final SnAccountStatus _self; + final $Res Function(SnAccountStatus) _then; + +/// Create a copy of SnAccountStatus +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? attitude = null,Object? isOnline = null,Object? isInvisible = null,Object? isNotDisturb = null,Object? isCustomized = null,Object? label = null,Object? clearedAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,attitude: null == attitude ? _self.attitude : attitude // ignore: cast_nullable_to_non_nullable +as int,isOnline: null == isOnline ? _self.isOnline : isOnline // ignore: cast_nullable_to_non_nullable +as bool,isInvisible: null == isInvisible ? _self.isInvisible : isInvisible // ignore: cast_nullable_to_non_nullable +as bool,isNotDisturb: null == isNotDisturb ? _self.isNotDisturb : isNotDisturb // ignore: cast_nullable_to_non_nullable +as bool,isCustomized: null == isCustomized ? _self.isCustomized : isCustomized // ignore: cast_nullable_to_non_nullable +as bool,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String,clearedAt: freezed == clearedAt ? _self.clearedAt : clearedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as int,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?, + )); +} + +} + + +/// @nodoc +@JsonSerializable() + +class _SnAccountStatus implements SnAccountStatus { + const _SnAccountStatus({required this.id, required this.attitude, required this.isOnline, required this.isInvisible, required this.isNotDisturb, required this.isCustomized, this.label = "", required this.clearedAt, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt}); + factory _SnAccountStatus.fromJson(Map json) => _$SnAccountStatusFromJson(json); + +@override final String id; +@override final int attitude; +@override final bool isOnline; +@override final bool isInvisible; +@override final bool isNotDisturb; +@override final bool isCustomized; +@override@JsonKey() final String label; +@override final DateTime? clearedAt; +@override final int accountId; +@override final DateTime createdAt; +@override final DateTime updatedAt; +@override final DateTime? deletedAt; + +/// Create a copy of SnAccountStatus +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SnAccountStatusCopyWith<_SnAccountStatus> get copyWith => __$SnAccountStatusCopyWithImpl<_SnAccountStatus>(this, _$identity); + +@override +Map toJson() { + return _$SnAccountStatusToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAccountStatus&&(identical(other.id, id) || other.id == id)&&(identical(other.attitude, attitude) || other.attitude == attitude)&&(identical(other.isOnline, isOnline) || other.isOnline == isOnline)&&(identical(other.isInvisible, isInvisible) || other.isInvisible == isInvisible)&&(identical(other.isNotDisturb, isNotDisturb) || other.isNotDisturb == isNotDisturb)&&(identical(other.isCustomized, isCustomized) || other.isCustomized == isCustomized)&&(identical(other.label, label) || other.label == label)&&(identical(other.clearedAt, clearedAt) || other.clearedAt == clearedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,attitude,isOnline,isInvisible,isNotDisturb,isCustomized,label,clearedAt,accountId,createdAt,updatedAt,deletedAt); + +@override +String toString() { + return 'SnAccountStatus(id: $id, attitude: $attitude, isOnline: $isOnline, isInvisible: $isInvisible, isNotDisturb: $isNotDisturb, isCustomized: $isCustomized, label: $label, clearedAt: $clearedAt, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)'; +} + + +} + +/// @nodoc +abstract mixin class _$SnAccountStatusCopyWith<$Res> implements $SnAccountStatusCopyWith<$Res> { + factory _$SnAccountStatusCopyWith(_SnAccountStatus value, $Res Function(_SnAccountStatus) _then) = __$SnAccountStatusCopyWithImpl; +@override @useResult +$Res call({ + String id, int attitude, bool isOnline, bool isInvisible, bool isNotDisturb, bool isCustomized, String label, DateTime? clearedAt, int accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt +}); + + + + +} +/// @nodoc +class __$SnAccountStatusCopyWithImpl<$Res> + implements _$SnAccountStatusCopyWith<$Res> { + __$SnAccountStatusCopyWithImpl(this._self, this._then); + + final _SnAccountStatus _self; + final $Res Function(_SnAccountStatus) _then; + +/// Create a copy of SnAccountStatus +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? attitude = null,Object? isOnline = null,Object? isInvisible = null,Object? isNotDisturb = null,Object? isCustomized = null,Object? label = null,Object? clearedAt = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) { + return _then(_SnAccountStatus( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,attitude: null == attitude ? _self.attitude : attitude // ignore: cast_nullable_to_non_nullable +as int,isOnline: null == isOnline ? _self.isOnline : isOnline // ignore: cast_nullable_to_non_nullable +as bool,isInvisible: null == isInvisible ? _self.isInvisible : isInvisible // ignore: cast_nullable_to_non_nullable +as bool,isNotDisturb: null == isNotDisturb ? _self.isNotDisturb : isNotDisturb // ignore: cast_nullable_to_non_nullable +as bool,isCustomized: null == isCustomized ? _self.isCustomized : isCustomized // ignore: cast_nullable_to_non_nullable +as bool,label: null == label ? _self.label : label // ignore: cast_nullable_to_non_nullable +as String,clearedAt: freezed == clearedAt ? _self.clearedAt : clearedAt // ignore: cast_nullable_to_non_nullable +as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable +as int,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?, + )); +} + + +} + // dart format on diff --git a/lib/models/user.g.dart b/lib/models/user.g.dart index db9a296..f8756bd 100644 --- a/lib/models/user.g.dart +++ b/lib/models/user.g.dart @@ -76,3 +76,41 @@ Map _$SnAccountProfileToJson(_SnAccountProfile instance) => 'updated_at': instance.updatedAt.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(), }; + +_SnAccountStatus _$SnAccountStatusFromJson(Map json) => + _SnAccountStatus( + id: json['id'] as String, + attitude: (json['attitude'] as num).toInt(), + isOnline: json['is_online'] as bool, + isInvisible: json['is_invisible'] as bool, + isNotDisturb: json['is_not_disturb'] as bool, + isCustomized: json['is_customized'] as bool, + label: json['label'] as String? ?? "", + clearedAt: + json['cleared_at'] == null + ? null + : DateTime.parse(json['cleared_at'] as String), + accountId: (json['account_id'] as num).toInt(), + 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), + ); + +Map _$SnAccountStatusToJson(_SnAccountStatus instance) => + { + 'id': instance.id, + 'attitude': instance.attitude, + 'is_online': instance.isOnline, + 'is_invisible': instance.isInvisible, + 'is_not_disturb': instance.isNotDisturb, + 'is_customized': instance.isCustomized, + 'label': instance.label, + 'cleared_at': instance.clearedAt?.toIso8601String(), + 'account_id': instance.accountId, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + }; diff --git a/lib/pods/websocket.dart b/lib/pods/websocket.dart index bcd4ccc..c3d0b16 100644 --- a/lib/pods/websocket.dart +++ b/lib/pods/websocket.dart @@ -59,6 +59,7 @@ class WebSocketService { Uri.parse(url), headers: {'Authorization': 'Bearer $atk'}, ); + // TODO Fix the atk is expired when reconnecting await _channel!.ready; _statusStreamController.sink.add(WebSocketState.connected()); _channel!.stream.listen( diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 31b81b3..23bb6b2 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -9,6 +9,7 @@ import 'package:island/pods/message.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/userinfo.dart'; import 'package:island/route.gr.dart'; +import 'package:island/widgets/account/status.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -31,68 +32,105 @@ class AccountScreen extends HookConsumerWidget { body: SingleChildScrollView( child: Column( children: [ - GestureDetector( - child: Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (user.value?.profile.background != null) - ClipRRect( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(8), - topRight: Radius.circular(8), - ), - child: AspectRatio( - aspectRatio: 16 / 7, - child: CloudFileWidget( - item: user.value!.profile.background!, - fit: BoxFit.cover, - ), + Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (user.value?.profile.background != null) + ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + child: AspectRatio( + aspectRatio: 16 / 7, + child: CloudFileWidget( + item: user.value!.profile.background!, + fit: BoxFit.cover, ), ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - spacing: 16, - children: [ - ProfilePictureWidget( + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 16, + children: [ + GestureDetector( + child: ProfilePictureWidget( fileId: user.value?.profile.pictureId, radius: 24, ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - spacing: 4, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text(user.value!.nick).bold().fontSize(16), - Text('@${user.value!.name}'), - ], - ), - Text( - user.value!.profile.bio ?? 'No description yet.', - ), - ], - ), - ], - ).padding(horizontal: 16, vertical: 16), - ], + onTap: () { + context.router.push( + AccountProfileRoute(name: user.value!.name), + ); + }, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text(user.value!.nick).bold().fontSize(16), + Text('@${user.value!.name}'), + ], + ), + Text( + user.value!.profile.bio ?? 'No description yet.', + ), + ], + ), + ], + ).padding(horizontal: 16, top: 16), + AccountStatusCreationWidget(uname: user.value!.name), + ], + ), + ).padding(horizontal: 8), + Row( + children: [ + Expanded( + child: Card( + child: InkWell( + borderRadius: BorderRadius.circular(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Symbols.draw, size: 28).padding(bottom: 8), + Text('creatorHub').tr().fontSize(16).bold(), + Text('creatorHubDescription').tr(), + ], + ).padding(horizontal: 16, vertical: 12), + onTap: () {}, + ), + ), ), - ).padding(horizontal: 8), - onTap: () { - context.router.push( - AccountProfileRoute(name: user.value!.name), - ); - }, - ), + Expanded( + child: Card( + child: InkWell( + borderRadius: BorderRadius.circular(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Symbols.code, size: 28).padding(bottom: 8), + Text('developerPortal').tr().fontSize(16).bold(), + Text('developerPortalDescription').tr(), + ], + ).padding(horizontal: 16, vertical: 12), + onTap: () {}, + ), + ), + ), + ], + ).padding(horizontal: 8), const Gap(8), ListTile( minTileHeight: 48, leading: const Icon(Symbols.public), trailing: const Icon(Symbols.chevron_right), contentPadding: EdgeInsets.symmetric(horizontal: 24), - title: Text('managedPublisher').tr(), + title: Text('publishers').tr(), onTap: () { context.router.push(ManagedPublisherRoute()); }, diff --git a/lib/widgets/account/status.dart b/lib/widgets/account/status.dart new file mode 100644 index 0000000..9e767f6 --- /dev/null +++ b/lib/widgets/account/status.dart @@ -0,0 +1,130 @@ +import 'package:dio/dio.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/user.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/widgets/account/status_creation.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:styled_widget/styled_widget.dart'; + +part 'status.g.dart'; + +@riverpod +Future accountStatus(Ref ref, String uname) async { + final apiClient = ref.watch(apiClientProvider); + try { + final resp = await apiClient.get('/accounts/$uname/statuses'); + return SnAccountStatus.fromJson(resp.data); + } catch (err) { + if (err is DioException) { + if (err.response?.statusCode == 404) { + return null; + } + } + rethrow; + } +} + +class AccountStatusCreationWidget extends HookConsumerWidget { + final String uname; + final EdgeInsets? padding; + const AccountStatusCreationWidget({ + super.key, + required this.uname, + this.padding, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userStatus = ref.watch(accountStatusProvider(uname)); + + return InkWell( + borderRadius: BorderRadius.circular(8), + child: userStatus.when( + data: + (status) => + (status?.isCustomized ?? false) + ? AccountStatusWidget(uname: uname) + : Padding( + padding: + padding ?? + EdgeInsets.symmetric(horizontal: 27, vertical: 4), + child: Row( + spacing: 4, + children: [ + Icon(Symbols.keyboard_arrow_up), + Text('statusCreateHint').tr(), + ], + ), + ).opacity(0.85), + error: + (error, _) => Padding( + padding: + padding ?? EdgeInsets.symmetric(horizontal: 26, vertical: 4), + child: Row( + spacing: 4, + children: [Icon(Symbols.close), Text('Error: $error')], + ), + ).opacity(0.85), + loading: + () => Padding( + padding: + padding ?? EdgeInsets.symmetric(horizontal: 26, vertical: 4), + child: Row( + spacing: 4, + children: [Icon(Symbols.more_vert), Text('loading').tr()], + ), + ).opacity(0.85), + ), + onTap: () { + showModalBottomSheet( + context: context, + builder: + (context) => AccountStatusCreationSheet( + initialStatus: + (userStatus.value?.isCustomized ?? false) + ? userStatus.value + : null, + ), + ); + }, + ); + } +} + +class AccountStatusWidget extends HookConsumerWidget { + final String uname; + final EdgeInsets? padding; + const AccountStatusWidget({super.key, required this.uname, this.padding}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userStatus = ref.watch(accountStatusProvider(uname)); + + return Padding( + padding: padding ?? EdgeInsets.symmetric(horizontal: 27, vertical: 4), + child: Row( + spacing: 4, + children: [ + if (!(userStatus.value?.isCustomized ?? false)) + Icon(Symbols.keyboard_arrow_up) + else if (userStatus.value!.isOnline) + Icon( + Symbols.circle, + fill: 1, + color: Colors.green, + size: 16, + ).padding(all: 4) + else + Icon(Symbols.circle, color: Colors.grey, size: 16).padding(all: 4), + if (userStatus.value?.isCustomized ?? false) + Text(userStatus.value?.label ?? 'unknown'.tr()) + else + Text((userStatus.value?.label ?? 'offline').toLowerCase()).tr(), + ], + ), + ).opacity((userStatus.value?.isCustomized ?? false) ? 1 : 0.85); + } +} diff --git a/lib/widgets/account/status.g.dart b/lib/widgets/account/status.g.dart new file mode 100644 index 0000000..a041d14 --- /dev/null +++ b/lib/widgets/account/status.g.dart @@ -0,0 +1,153 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'status.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$accountStatusHash() => r'8c3ba5242da1d1e75e3cbf1f2934ff7d5683d0d6'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [accountStatus]. +@ProviderFor(accountStatus) +const accountStatusProvider = AccountStatusFamily(); + +/// See also [accountStatus]. +class AccountStatusFamily extends Family> { + /// See also [accountStatus]. + const AccountStatusFamily(); + + /// See also [accountStatus]. + AccountStatusProvider call(String uname) { + return AccountStatusProvider(uname); + } + + @override + AccountStatusProvider getProviderOverride( + covariant AccountStatusProvider provider, + ) { + return call(provider.uname); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'accountStatusProvider'; +} + +/// See also [accountStatus]. +class AccountStatusProvider + extends AutoDisposeFutureProvider { + /// See also [accountStatus]. + AccountStatusProvider(String uname) + : this._internal( + (ref) => accountStatus(ref as AccountStatusRef, uname), + from: accountStatusProvider, + name: r'accountStatusProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$accountStatusHash, + dependencies: AccountStatusFamily._dependencies, + allTransitiveDependencies: + AccountStatusFamily._allTransitiveDependencies, + uname: uname, + ); + + AccountStatusProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.uname, + }) : super.internal(); + + final String uname; + + @override + Override overrideWith( + FutureOr Function(AccountStatusRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: AccountStatusProvider._internal( + (ref) => create(ref as AccountStatusRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + uname: uname, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _AccountStatusProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is AccountStatusProvider && other.uname == uname; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, uname.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin AccountStatusRef on AutoDisposeFutureProviderRef { + /// The parameter `uname` of this provider. + String get uname; +} + +class _AccountStatusProviderElement + extends AutoDisposeFutureProviderElement + with AccountStatusRef { + _AccountStatusProviderElement(super.provider); + + @override + String get uname => (origin as AccountStatusProvider).uname; +} + +// 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 diff --git a/lib/widgets/account/status_creation.dart b/lib/widgets/account/status_creation.dart new file mode 100644 index 0000000..58f0502 --- /dev/null +++ b/lib/widgets/account/status_creation.dart @@ -0,0 +1,249 @@ +import 'package:dio/dio.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:island/models/user.dart'; +import 'package:island/pods/network.dart'; +import 'package:island/pods/userinfo.dart'; +import 'package:island/widgets/account/status.dart'; +import 'package:island/widgets/alert.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +class AccountStatusCreationSheet extends HookConsumerWidget { + final SnAccountStatus? initialStatus; + const AccountStatusCreationSheet({super.key, this.initialStatus}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final attitude = useState(initialStatus?.attitude ?? 1); + final isInvisible = useState(initialStatus?.isInvisible ?? false); + final isNotDisturb = useState(initialStatus?.isNotDisturb ?? false); + final clearedAt = useState(initialStatus?.clearedAt); + final labelController = useTextEditingController( + text: initialStatus?.label ?? '', + ); + + final submitting = useState(false); + + Future clearStatus() async { + try { + submitting.value = true; + final user = ref.watch(userInfoProvider); + final apiClient = ref.read(apiClientProvider); + await apiClient.delete('/accounts/me/statuses'); + if (!context.mounted) return; + ref.invalidate(accountStatusProvider(user.value!.name)); + Navigator.pop(context); + } catch (err) { + showErrorAlert(err); + } finally { + submitting.value = false; + } + } + + Future submitStatus() async { + try { + submitting.value = true; + final user = ref.watch(userInfoProvider); + final apiClient = ref.read(apiClientProvider); + await apiClient.request( + '/accounts/me/statuses', + data: { + 'attitude': attitude.value, + 'is_invisible': isInvisible.value, + 'is_not_disturb': isNotDisturb.value, + 'cleared_at': clearedAt.value?.toIso8601String(), + if (labelController.text.isNotEmpty) 'label': labelController.text, + }, + options: Options(method: initialStatus == null ? 'POST' : 'PATCH'), + ); + if (user.hasValue) { + ref.invalidate(accountStatusProvider(user.value!.name)); + } + if (!context.mounted) return; + Navigator.pop(context); + } catch (err) { + showErrorAlert(err); + } finally { + submitting.value = false; + } + } + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: Column( + children: [ + Padding( + padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12), + child: Row( + children: [ + Text( + initialStatus == null + ? 'statusCreate'.tr() + : 'statusUpdate'.tr(), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: -0.5, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: + submitting.value + ? null + : () { + submitStatus(); + }, + icon: const Icon(Symbols.upload), + label: Text(initialStatus == null ? 'create' : 'update').tr(), + style: ButtonStyle( + visualDensity: VisualDensity( + horizontal: VisualDensity.minimumDensity, + ), + foregroundColor: WidgetStatePropertyAll( + Theme.of(context).colorScheme.onSurface, + ), + ), + ), + if (initialStatus != null) + IconButton( + icon: const Icon(Symbols.delete), + onPressed: submitting.value ? null : () => clearStatus(), + style: IconButton.styleFrom( + minimumSize: const Size(36, 36), + ), + ), + IconButton( + icon: const Icon(Symbols.close), + onPressed: () => Navigator.pop(context), + style: IconButton.styleFrom(minimumSize: const Size(36, 36)), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Gap(24), + TextField( + controller: labelController, + decoration: InputDecoration( + labelText: 'statusLabel'.tr(), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + ), + onTapOutside: + (_) => FocusManager.instance.primaryFocus?.unfocus(), + ), + const SizedBox(height: 24), + Text( + 'statusAttitude'.tr(), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + SegmentedButton( + segments: [ + ButtonSegment( + value: 0, + icon: const Icon(Symbols.sentiment_satisfied), + label: Text('attitudePositive'.tr()), + ), + ButtonSegment( + value: 1, + icon: const Icon(Symbols.sentiment_stressed), + label: Text('attitudeNeutral'.tr()), + ), + ButtonSegment( + value: 2, + icon: const Icon(Symbols.sentiment_sad), + label: Text('attitudeNegative'.tr()), + ), + ], + selected: {attitude.value}, + onSelectionChanged: (Set newSelection) { + attitude.value = newSelection.first; + }, + ), + const Gap(12), + SwitchListTile( + title: Text('statusInvisible'.tr()), + subtitle: Text('statusInvisibleDescription'.tr()), + value: isInvisible.value, + contentPadding: EdgeInsets.symmetric(horizontal: 8), + onChanged: (bool value) { + isInvisible.value = value; + }, + ), + SwitchListTile( + title: Text('statusNotDisturb'.tr()), + subtitle: Text('statusNotDisturbDescription'.tr()), + value: isNotDisturb.value, + contentPadding: EdgeInsets.symmetric(horizontal: 8), + onChanged: (bool value) { + isNotDisturb.value = value; + }, + ), + const SizedBox(height: 24), + Text( + 'statusClearTime'.tr(), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + ListTile( + title: Text( + clearedAt.value == null + ? 'statusNoAutoClear'.tr() + : DateFormat.yMMMd().add_jm().format( + clearedAt.value!, + ), + ), + trailing: const Icon(Symbols.schedule), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: Theme.of(context).colorScheme.outline, + ), + ), + onTap: () async { + final now = DateTime.now(); + final date = await showDatePicker( + context: context, + initialDate: now, + firstDate: now, + lastDate: now.add(const Duration(days: 365)), + ); + if (date == null) return; + if (!context.mounted) return; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (time == null) return; + clearedAt.value = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + }, + ), + Gap(MediaQuery.of(context).padding.bottom + 24), + ], + ), + ), + ), + ], + ), + ); + } +}