Status creation & update & clear

This commit is contained in:
2025-05-10 12:38:34 +08:00
parent d678cc571a
commit fb6e65a0fb
9 changed files with 868 additions and 51 deletions

View File

@ -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<SnAccountStatus?> 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);
}
}

View File

@ -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<AsyncValue<SnAccountStatus?>> {
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'accountStatusProvider';
}
/// See also [accountStatus].
class AccountStatusProvider
extends AutoDisposeFutureProvider<SnAccountStatus?> {
/// 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<SnAccountStatus?> 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<SnAccountStatus?> 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<SnAccountStatus?> {
/// The parameter `uname` of this provider.
String get uname;
}
class _AccountStatusProviderElement
extends AutoDisposeFutureProviderElement<SnAccountStatus?>
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

View File

@ -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<int>(initialStatus?.attitude ?? 1);
final isInvisible = useState(initialStatus?.isInvisible ?? false);
final isNotDisturb = useState(initialStatus?.isNotDisturb ?? false);
final clearedAt = useState<DateTime?>(initialStatus?.clearedAt);
final labelController = useTextEditingController(
text: initialStatus?.label ?? '',
);
final submitting = useState(false);
Future<void> 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<void> 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<int> 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),
],
),
),
),
],
),
);
}
}