🎨 Use feature based folder structure
This commit is contained in:
367
lib/accounts/accounts_widgets/account/account_devices.dart
Normal file
367
lib/accounts/accounts_widgets/account/account_devices.dart
Normal file
@@ -0,0 +1,367 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_models/account.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/services/responsive.dart';
|
||||
import 'package:island/core/services/time.dart';
|
||||
import 'package:island/core/services/udid.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:island/shared/widgets/response.dart';
|
||||
import 'package:island/shared/widgets/info_row.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:island/shared/widgets/extended_refresh_indicator.dart';
|
||||
|
||||
part 'account_devices.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<SnAuthDeviceWithSession>> authDevices(Ref ref) async {
|
||||
final resp = await ref
|
||||
.watch(apiClientProvider)
|
||||
.get('/pass/accounts/me/devices');
|
||||
final currentId = await getUdid();
|
||||
final data = resp.data.map<SnAuthDeviceWithSession>((e) {
|
||||
final ele = SnAuthDeviceWithSession.fromJson(e);
|
||||
return ele.copyWith(isCurrent: ele.deviceId == currentId);
|
||||
}).toList();
|
||||
return data;
|
||||
}
|
||||
|
||||
class _DeviceListTile extends StatelessWidget {
|
||||
final SnAuthDeviceWithSession device;
|
||||
final Function(String) updateDeviceLabel;
|
||||
final Function(String) logoutDevice;
|
||||
final Function(String) logoutSession;
|
||||
|
||||
const _DeviceListTile({
|
||||
required this.device,
|
||||
required this.updateDeviceLabel,
|
||||
required this.logoutDevice,
|
||||
required this.logoutSession,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ExpansionTile(
|
||||
title: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Flexible(child: Text(device.deviceLabel ?? device.deviceName)),
|
||||
if (device.isCurrent)
|
||||
Row(
|
||||
children: [
|
||||
Badge(
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
label: Text(
|
||||
'authDeviceCurrent'.tr(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (device.sessions.isNotEmpty)
|
||||
Text(
|
||||
'lastActiveAt'.tr(
|
||||
args: [device.sessions.first.createdAt.formatSystem()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
leading: Icon(switch (device.platform) {
|
||||
0 => Icons.device_unknown, // Unidentified
|
||||
1 => Icons.web, // Web
|
||||
2 => Icons.phone_iphone, // iOS
|
||||
3 => Icons.phone_android, // Android
|
||||
4 => Icons.laptop_mac, // macOS
|
||||
5 => Icons.window, // Windows
|
||||
6 => Icons.computer, // Linux
|
||||
_ => Icons.device_unknown, // fallback
|
||||
}).padding(top: 4),
|
||||
trailing: isWideScreen(context)
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.edit),
|
||||
tooltip: 'authDeviceEditLabel'.tr(),
|
||||
onPressed: () => updateDeviceLabel(device.deviceId),
|
||||
),
|
||||
if (!device.isCurrent)
|
||||
IconButton(
|
||||
icon: Icon(Icons.logout),
|
||||
tooltip: 'authDeviceLogout'.tr(),
|
||||
onPressed: () => logoutDevice(device.deviceId),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
expandedCrossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text('authDeviceChallenges'.tr()),
|
||||
),
|
||||
...device.sessions
|
||||
.map(
|
||||
(session) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
InfoRow(
|
||||
label: 'createdAt'.tr(
|
||||
args: [session.createdAt.toLocal().formatSystem()],
|
||||
),
|
||||
icon: Symbols.join,
|
||||
),
|
||||
InfoRow(
|
||||
label: 'lastActiveAt'.tr(
|
||||
args: [
|
||||
session.lastGrantedAt.toLocal().formatSystem(),
|
||||
],
|
||||
),
|
||||
icon: Symbols.refresh_rounded,
|
||||
),
|
||||
InfoRow(
|
||||
label:
|
||||
'${'location'.tr()} ${session.location?.city ?? 'unknown'.tr()}',
|
||||
icon: Symbols.pin_drop,
|
||||
),
|
||||
InfoRow(
|
||||
label:
|
||||
'${'ipAddress'.tr()} ${session.ipAddress ?? 'unknown'.tr()}',
|
||||
icon: Symbols.dns,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.logout),
|
||||
tooltip: 'authSessionLogout'.tr(),
|
||||
onPressed: () => logoutSession(session.id),
|
||||
),
|
||||
const Gap(4),
|
||||
],
|
||||
).padding(horizontal: 20, vertical: 8),
|
||||
)
|
||||
.expand((element) => [element, const Divider(height: 1)])
|
||||
.toList()
|
||||
..removeLast(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountSessionSheet extends HookConsumerWidget {
|
||||
const AccountSessionSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final authDevices = ref.watch(authDevicesProvider);
|
||||
|
||||
void logoutDevice(String sessionId) async {
|
||||
final confirm = await showConfirmAlert(
|
||||
'authDeviceLogoutHint'.tr(),
|
||||
'authDeviceLogout'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.delete('/pass/accounts/me/devices/$sessionId');
|
||||
ref.invalidate(authDevicesProvider);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
void logoutSession(String sessionId) async {
|
||||
final confirm = await showConfirmAlert(
|
||||
'authSessionLogoutHint'.tr(),
|
||||
'authSessionLogout'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (!confirm || !context.mounted) return;
|
||||
try {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.delete('/pass/accounts/me/sessions/$sessionId');
|
||||
ref.invalidate(authDevicesProvider);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
void updateDeviceLabel(String sessionId) async {
|
||||
final controller = TextEditingController();
|
||||
final label = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('authDeviceLabelTitle'.tr()),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'authDeviceLabelHint'.tr(),
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text),
|
||||
child: Text('confirm'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (label == null || label.isEmpty || !context.mounted) return;
|
||||
try {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.patch(
|
||||
'/pass/accounts/me/devices/$sessionId/label',
|
||||
data: jsonEncode(label),
|
||||
);
|
||||
ref.invalidate(authDevicesProvider);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
}
|
||||
|
||||
final wideScreen = isWideScreen(context);
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'authSessions'.tr(),
|
||||
child: Column(
|
||||
children: [
|
||||
if (!wideScreen)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
const Icon(Symbols.info, size: 16).padding(top: 2),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'authDeviceHint'.tr(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: authDevices.when(
|
||||
data: (data) => ExtendedRefreshIndicator(
|
||||
onRefresh: () =>
|
||||
Future.sync(() => ref.invalidate(authDevicesProvider)),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: data.length,
|
||||
itemBuilder: (context, index) {
|
||||
final device = data[index];
|
||||
if (wideScreen) {
|
||||
return _DeviceListTile(
|
||||
device: device,
|
||||
updateDeviceLabel: updateDeviceLabel,
|
||||
logoutDevice: logoutDevice,
|
||||
logoutSession: logoutSession,
|
||||
);
|
||||
} else {
|
||||
return Dismissible(
|
||||
key: Key('device-${device.id}'),
|
||||
direction: device.isCurrent
|
||||
? DismissDirection.startToEnd
|
||||
: DismissDirection.horizontal,
|
||||
background: Container(
|
||||
color: Colors.blue,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Icon(Icons.edit, color: Colors.white),
|
||||
),
|
||||
secondaryBackground: Container(
|
||||
color: Colors.red,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Icon(Icons.logout, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (direction) async {
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
updateDeviceLabel(device.deviceId);
|
||||
return false;
|
||||
} else {
|
||||
final confirm = await showConfirmAlert(
|
||||
'authDeviceLogoutHint'.tr(),
|
||||
'authDeviceLogout'.tr(),
|
||||
isDanger: true,
|
||||
);
|
||||
if (confirm && context.mounted) {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.delete(
|
||||
'/pass/accounts/me/devices/${device.deviceId}',
|
||||
);
|
||||
ref.invalidate(authDevicesProvider);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
hideLoadingModal(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
return confirm;
|
||||
}
|
||||
},
|
||||
child: _DeviceListTile(
|
||||
device: device,
|
||||
updateDeviceLabel: updateDeviceLabel,
|
||||
logoutDevice: logoutDevice,
|
||||
logoutSession: logoutSession,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
error: (err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => ref.invalidate(authDevicesProvider),
|
||||
),
|
||||
loading: () => ResponseLoadingWidget(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
51
lib/accounts/accounts_widgets/account/account_devices.g.dart
Normal file
51
lib/accounts/accounts_widgets/account/account_devices.g.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_devices.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(authDevices)
|
||||
final authDevicesProvider = AuthDevicesProvider._();
|
||||
|
||||
final class AuthDevicesProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnAuthDeviceWithSession>>,
|
||||
List<SnAuthDeviceWithSession>,
|
||||
FutureOr<List<SnAuthDeviceWithSession>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SnAuthDeviceWithSession>>,
|
||||
$FutureProvider<List<SnAuthDeviceWithSession>> {
|
||||
AuthDevicesProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authDevicesProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authDevicesHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SnAuthDeviceWithSession>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SnAuthDeviceWithSession>> create(Ref ref) {
|
||||
return authDevices(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$authDevicesHash() => r'1af378149286020ec263be178c573ccc247a0cd1';
|
||||
475
lib/accounts/accounts_widgets/account/account_name.dart
Normal file
475
lib/accounts/accounts_widgets/account/account_name.dart
Normal file
@@ -0,0 +1,475 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_models/account.dart';
|
||||
import 'package:island/wallet/wallet_models/wallet.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
const Map<String, Color> kUsernamePlainColors = {
|
||||
'red': Colors.red,
|
||||
'blue': Colors.blue,
|
||||
'green': Colors.green,
|
||||
'yellow': Colors.yellow,
|
||||
'purple': Colors.purple,
|
||||
'orange': Colors.orange,
|
||||
'pink': Colors.pink,
|
||||
'cyan': Colors.cyan,
|
||||
'lime': Colors.lime,
|
||||
'indigo': Colors.indigo,
|
||||
'teal': Colors.teal,
|
||||
'amber': Colors.amber,
|
||||
'brown': Colors.brown,
|
||||
'grey': Colors.grey,
|
||||
'black': Colors.black,
|
||||
'white': Colors.white,
|
||||
};
|
||||
|
||||
const List<IconData> kVerificationMarkIcons = [
|
||||
Symbols.build_circle,
|
||||
Symbols.verified,
|
||||
Symbols.verified,
|
||||
Symbols.account_balance,
|
||||
Symbols.palette,
|
||||
Symbols.code,
|
||||
Symbols.masks,
|
||||
];
|
||||
|
||||
const List<Color> kVerificationMarkColors = [
|
||||
Colors.teal,
|
||||
Colors.lightBlue,
|
||||
Colors.indigo,
|
||||
Colors.red,
|
||||
Colors.orange,
|
||||
Colors.blue,
|
||||
Colors.blueAccent,
|
||||
];
|
||||
|
||||
class AccountName extends StatelessWidget {
|
||||
final SnAccount account;
|
||||
final TextStyle? style;
|
||||
final String? textOverride;
|
||||
final bool ignorePermissions;
|
||||
final bool hideVerificationMark;
|
||||
final bool hideOverlay;
|
||||
const AccountName({
|
||||
super.key,
|
||||
required this.account,
|
||||
this.style,
|
||||
this.textOverride,
|
||||
this.ignorePermissions = false,
|
||||
this.hideVerificationMark = false,
|
||||
this.hideOverlay = false,
|
||||
});
|
||||
|
||||
Alignment _parseGradientDirection(String direction) {
|
||||
switch (direction) {
|
||||
case 'to right':
|
||||
return Alignment.centerLeft;
|
||||
case 'to left':
|
||||
return Alignment.centerRight;
|
||||
case 'to bottom':
|
||||
return Alignment.topCenter;
|
||||
case 'to top':
|
||||
return Alignment.bottomCenter;
|
||||
case 'to bottom right':
|
||||
return Alignment.topLeft;
|
||||
case 'to bottom left':
|
||||
return Alignment.topRight;
|
||||
case 'to top right':
|
||||
return Alignment.bottomLeft;
|
||||
case 'to top left':
|
||||
return Alignment.bottomRight;
|
||||
default:
|
||||
return Alignment.centerLeft;
|
||||
}
|
||||
}
|
||||
|
||||
Alignment _parseGradientEnd(String direction) {
|
||||
switch (direction) {
|
||||
case 'to right':
|
||||
return Alignment.centerRight;
|
||||
case 'to left':
|
||||
return Alignment.centerLeft;
|
||||
case 'to bottom':
|
||||
return Alignment.bottomCenter;
|
||||
case 'to top':
|
||||
return Alignment.topCenter;
|
||||
case 'to bottom right':
|
||||
return Alignment.bottomRight;
|
||||
case 'to bottom left':
|
||||
return Alignment.bottomLeft;
|
||||
case 'to top right':
|
||||
return Alignment.topRight;
|
||||
case 'to top left':
|
||||
return Alignment.topLeft;
|
||||
default:
|
||||
return Alignment.centerRight;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var nameStyle = (style ?? TextStyle());
|
||||
|
||||
// Apply username color based on membership tier and custom settings
|
||||
if (account.profile.usernameColor != null) {
|
||||
final usernameColor = account.profile.usernameColor!;
|
||||
final tier = account.perkSubscription?.identifier;
|
||||
|
||||
// Check tier restrictions
|
||||
final canUseCustomColor =
|
||||
ignorePermissions ||
|
||||
switch (tier) {
|
||||
'solian.stellar.primary' =>
|
||||
usernameColor.type == 'plain' &&
|
||||
kUsernamePlainColors.containsKey(usernameColor.value),
|
||||
'solian.stellar.nova' => usernameColor.type == 'plain',
|
||||
'solian.stellar.supernova' => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (canUseCustomColor) {
|
||||
if (usernameColor.type == 'plain') {
|
||||
// Plain color
|
||||
Color? color;
|
||||
if (kUsernamePlainColors.containsKey(usernameColor.value)) {
|
||||
color = kUsernamePlainColors[usernameColor.value];
|
||||
} else if (usernameColor.value != null) {
|
||||
// Try to parse hex color
|
||||
try {
|
||||
color = Color(
|
||||
int.parse(
|
||||
usernameColor.value!.replaceFirst('#', ''),
|
||||
radix: 16,
|
||||
) +
|
||||
0xFF000000,
|
||||
);
|
||||
} catch (_) {
|
||||
// Invalid hex, ignore
|
||||
}
|
||||
}
|
||||
if (color != null) {
|
||||
nameStyle = nameStyle.copyWith(color: color);
|
||||
}
|
||||
} else if (usernameColor.type == 'gradient' &&
|
||||
usernameColor.colors != null &&
|
||||
usernameColor.colors!.isNotEmpty) {
|
||||
// Gradient - use ShaderMask for text gradient
|
||||
final colors = <Color>[];
|
||||
for (final colorStr in usernameColor.colors!) {
|
||||
Color? color;
|
||||
if (kUsernamePlainColors.containsKey(colorStr)) {
|
||||
color = kUsernamePlainColors[colorStr];
|
||||
} else {
|
||||
// Try to parse hex color
|
||||
try {
|
||||
color = Color(
|
||||
int.parse(colorStr.replaceFirst('#', ''), radix: 16) +
|
||||
0xFF000000,
|
||||
);
|
||||
} catch (_) {
|
||||
// Invalid hex, skip
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (color != null) {
|
||||
colors.add(color);
|
||||
}
|
||||
}
|
||||
|
||||
if (colors.isNotEmpty) {
|
||||
final gradient = LinearGradient(
|
||||
colors: colors,
|
||||
begin: _parseGradientDirection(
|
||||
usernameColor.direction ?? 'to right',
|
||||
),
|
||||
end: _parseGradientEnd(usernameColor.direction ?? 'to right'),
|
||||
);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Flexible(
|
||||
child: ShaderMask(
|
||||
shaderCallback: (bounds) => gradient.createShader(bounds),
|
||||
child: Text(
|
||||
textOverride ?? account.nick,
|
||||
style: nameStyle.copyWith(color: Colors.white),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (account.perkSubscription != null)
|
||||
StellarMembershipMark(
|
||||
membership: account.perkSubscription!,
|
||||
hideOverlay: hideOverlay,
|
||||
),
|
||||
if (account.profile.verification != null &&
|
||||
!hideVerificationMark)
|
||||
VerificationMark(
|
||||
mark: account.profile.verification!,
|
||||
hideOverlay: hideOverlay,
|
||||
),
|
||||
if (account.automatedId != null)
|
||||
hideOverlay
|
||||
? Icon(
|
||||
Symbols.smart_toy,
|
||||
size: 16,
|
||||
color: nameStyle.color,
|
||||
fill: 1,
|
||||
)
|
||||
: Tooltip(
|
||||
message: 'accountAutomated'.tr(),
|
||||
child: Icon(
|
||||
Symbols.smart_toy,
|
||||
size: 16,
|
||||
color: nameStyle.color,
|
||||
fill: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (account.perkSubscription != null) {
|
||||
// Default membership colors if no custom color is set
|
||||
nameStyle = nameStyle.copyWith(
|
||||
color: (switch (account.perkSubscription!.identifier) {
|
||||
'solian.stellar.primary' => Colors.blueAccent,
|
||||
'solian.stellar.nova' => Color.fromRGBO(57, 197, 187, 1),
|
||||
'solian.stellar.supernova' => Colors.amberAccent,
|
||||
_ => null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
textOverride ?? account.nick,
|
||||
style: nameStyle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (account.perkSubscription != null)
|
||||
StellarMembershipMark(
|
||||
membership: account.perkSubscription!,
|
||||
hideOverlay: hideOverlay,
|
||||
),
|
||||
if (account.profile.verification != null)
|
||||
VerificationMark(
|
||||
mark: account.profile.verification!,
|
||||
hideOverlay: hideOverlay,
|
||||
),
|
||||
if (account.automatedId != null)
|
||||
hideOverlay
|
||||
? Icon(
|
||||
Symbols.smart_toy,
|
||||
size: 16,
|
||||
color: nameStyle.color,
|
||||
fill: 1,
|
||||
)
|
||||
: Tooltip(
|
||||
message: 'accountAutomated'.tr(),
|
||||
child: Icon(
|
||||
Symbols.smart_toy,
|
||||
size: 16,
|
||||
color: nameStyle.color,
|
||||
fill: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VerificationMark extends StatelessWidget {
|
||||
final SnVerificationMark mark;
|
||||
final bool hideOverlay;
|
||||
const VerificationMark({
|
||||
super.key,
|
||||
required this.mark,
|
||||
this.hideOverlay = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final icon = Icon(
|
||||
(kVerificationMarkIcons.length > mark.type && mark.type >= 0)
|
||||
? kVerificationMarkIcons[mark.type]
|
||||
: Symbols.verified,
|
||||
size: 16,
|
||||
color: (kVerificationMarkColors.length > mark.type && mark.type >= 0)
|
||||
? kVerificationMarkColors[mark.type]
|
||||
: Colors.blue,
|
||||
fill: 1,
|
||||
);
|
||||
|
||||
return hideOverlay
|
||||
? icon
|
||||
: Tooltip(
|
||||
richMessage: TextSpan(
|
||||
text: mark.title ?? 'No title',
|
||||
children: [
|
||||
TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: mark.description ?? 'descriptionNone'.tr(),
|
||||
style: TextStyle(fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
child: icon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StellarMembershipMark extends StatelessWidget {
|
||||
final SnWalletSubscriptionRef membership;
|
||||
final bool hideOverlay;
|
||||
const StellarMembershipMark({
|
||||
super.key,
|
||||
required this.membership,
|
||||
this.hideOverlay = false,
|
||||
});
|
||||
|
||||
String _getMembershipTierName(String identifier) {
|
||||
switch (identifier) {
|
||||
case 'solian.stellar.primary':
|
||||
return 'membershipTierStellar'.tr();
|
||||
case 'solian.stellar.nova':
|
||||
return 'membershipTierNova'.tr();
|
||||
case 'solian.stellar.supernova':
|
||||
return 'membershipTierSupernova'.tr();
|
||||
default:
|
||||
return 'membershipTierUnknown'.tr();
|
||||
}
|
||||
}
|
||||
|
||||
Color _getMembershipTierColor(String identifier) {
|
||||
switch (identifier) {
|
||||
case 'solian.stellar.primary':
|
||||
return Colors.blue;
|
||||
case 'solian.stellar.nova':
|
||||
return Color.fromRGBO(57, 197, 187, 1);
|
||||
case 'solian.stellar.supernova':
|
||||
return Colors.amber;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!membership.isActive) return const SizedBox.shrink();
|
||||
|
||||
final tierName = _getMembershipTierName(membership.identifier);
|
||||
final tierColor = _getMembershipTierColor(membership.identifier);
|
||||
final tierIcon = Symbols.kid_star;
|
||||
|
||||
final icon = Icon(tierIcon, size: 16, color: tierColor, fill: 1);
|
||||
|
||||
return hideOverlay
|
||||
? icon
|
||||
: Tooltip(
|
||||
richMessage: TextSpan(
|
||||
text: 'stellarMembership'.tr(),
|
||||
children: [
|
||||
TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: 'currentMembershipMember'.tr(args: [tierName]),
|
||||
style: TextStyle(fontWeight: FontWeight.normal),
|
||||
),
|
||||
],
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
child: icon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VerificationStatusCard extends StatelessWidget {
|
||||
final SnVerificationMark mark;
|
||||
const VerificationStatusCard({super.key, required this.mark});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
(kVerificationMarkIcons.length > mark.type && mark.type >= 0)
|
||||
? kVerificationMarkIcons[mark.type]
|
||||
: Symbols.verified,
|
||||
size: 32,
|
||||
color: (kVerificationMarkColors.length > mark.type && mark.type >= 0)
|
||||
? kVerificationMarkColors[mark.type]
|
||||
: Colors.blue,
|
||||
fill: 1,
|
||||
).alignment(Alignment.centerLeft),
|
||||
const Gap(8),
|
||||
Text(mark.title ?? 'No title').bold(),
|
||||
Text(mark.description ?? 'descriptionNone'.tr()),
|
||||
const Gap(6),
|
||||
Text(
|
||||
'Verified by\n${mark.verifiedBy ?? 'No one verified it'}',
|
||||
).fontSize(11).opacity(0.8),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountUnactivatedCard extends HookConsumerWidget {
|
||||
const AccountUnactivatedCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(
|
||||
Symbols.warning_amber_rounded,
|
||||
size: 40,
|
||||
fill: 1,
|
||||
color: Colors.amber,
|
||||
),
|
||||
const Gap(4),
|
||||
Text('accountActivationAlert').tr().fontSize(16).bold(),
|
||||
Text('accountActivationAlertHint').tr(),
|
||||
const Gap(4),
|
||||
Text('accountActivationResendHint').tr().opacity(0.8),
|
||||
const Gap(16),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Symbols.email),
|
||||
label: Text('accountActivationResend').tr(),
|
||||
onPressed: () async {
|
||||
final client = ref.watch(apiClientProvider);
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
await client.post('/pass/spells/activation/resend');
|
||||
showSnackBar("Activation magic spell has been resend");
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
},
|
||||
).width(double.infinity),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
);
|
||||
}
|
||||
}
|
||||
206
lib/accounts/accounts_widgets/account/account_nameplate.dart
Normal file
206
lib/accounts/accounts_widgets/account/account_nameplate.dart
Normal file
@@ -0,0 +1,206 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/accounts/account/profile.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_name.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
class AccountNameplate extends HookConsumerWidget {
|
||||
final String name;
|
||||
final bool isOutlined;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const AccountNameplate({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.isOutlined = true,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(accountProvider(name));
|
||||
|
||||
return Container(
|
||||
decoration: isOutlined
|
||||
? BoxDecoration(
|
||||
border: Border.all(
|
||||
width: 1 / MediaQuery.of(context).devicePixelRatio,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
)
|
||||
: null,
|
||||
margin: padding,
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
elevation: 0,
|
||||
color: Colors.transparent,
|
||||
child: user.when(
|
||||
data: (account) => account.profile.background != null
|
||||
? AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background image
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CloudFileWidget(
|
||||
item: account.profile.background!,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Gradient overlay for text readability
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.8),
|
||||
Colors.black.withOpacity(0.1),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Content positioned at the bottom
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Profile picture (equivalent to leading)
|
||||
ProfilePictureWidget(
|
||||
file: account.profile.picture,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Text content (equivalent to title and subtitle)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AccountName(
|
||||
account: account,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'@${account.name}',
|
||||
).textColor(Colors.white70),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
decoration: isOutlined
|
||||
? BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
// Profile picture (equivalent to leading)
|
||||
ProfilePictureWidget(file: account.profile.picture),
|
||||
const SizedBox(width: 16),
|
||||
// Text content (equivalent to title and subtitle)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AccountName(
|
||||
account: account,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text('@${account.name}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading: () => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Loading indicator (equivalent to leading)
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(width: 16),
|
||||
// Loading text content (equivalent to title and subtitle)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('loading').bold().tr(),
|
||||
const SizedBox(height: 4),
|
||||
const Text('...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
error: (error, stackTrace) => Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Error icon (equivalent to leading)
|
||||
const Icon(Symbols.error),
|
||||
const SizedBox(width: 16),
|
||||
// Error text content (equivalent to title and subtitle)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('somethingWentWrong').tr().bold(),
|
||||
const SizedBox(height: 4),
|
||||
Text(error.toString()),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
261
lib/accounts/accounts_widgets/account/account_pfc.dart
Normal file
261
lib/accounts/accounts_widgets/account/account_pfc.dart
Normal file
@@ -0,0 +1,261 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_popup_card/flutter_popup_card.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/account/profile.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_name.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/activity_presence.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/badge.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/status.dart';
|
||||
import 'package:island/core/services/time.dart';
|
||||
import 'package:island/core/services/timezone/native.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:island/shared/widgets/response.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class AccountProfileCard extends HookConsumerWidget {
|
||||
final String uname;
|
||||
const AccountProfileCard({super.key, required this.uname});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final account = ref.watch(accountProvider(uname));
|
||||
final width = math
|
||||
.min(MediaQuery.of(context).size.width - 80, 360)
|
||||
.toDouble();
|
||||
|
||||
final child = account.when(
|
||||
data: (data) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (data.profile.background != null)
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: CloudImageWidget(file: data.profile.background),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
child: Badge(
|
||||
isLabelVisible: true,
|
||||
padding: EdgeInsets.all(2),
|
||||
label: Icon(
|
||||
Symbols.launch,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
offset: Offset(4, 28),
|
||||
child: ProfilePictureWidget(file: data.profile.picture),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
context.pushNamed(
|
||||
'accountProfile',
|
||||
pathParameters: {'name': data.name},
|
||||
);
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountName(
|
||||
account: data,
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text('@${data.name}').fontSize(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(12),
|
||||
AccountStatusWidget(uname: data.name, padding: EdgeInsets.zero),
|
||||
Tooltip(
|
||||
message: 'creditsStatus'.tr(),
|
||||
child: Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.attribution,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text(
|
||||
'${data.profile.socialCredits.toStringAsFixed(2)} pts',
|
||||
).fontSize(12),
|
||||
switch (data.profile.socialCreditsLevel) {
|
||||
-1 => Text('socialCreditsLevelPoor').tr(),
|
||||
0 => Text('socialCreditsLevelNormal').tr(),
|
||||
1 => Text('socialCreditsLevelGood').tr(),
|
||||
2 => Text('socialCreditsLevelExcellent').tr(),
|
||||
_ => Text('unknown').tr(),
|
||||
}.fontSize(12),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (data.automatedId != null)
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.smart_toy,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text('accountAutomated').tr().fontSize(12),
|
||||
],
|
||||
),
|
||||
if (data.profile.timeZone.isNotEmpty && !kIsWeb)
|
||||
() {
|
||||
try {
|
||||
final tzInfo = getTzInfo(data.profile.timeZone);
|
||||
return Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.alarm,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text(
|
||||
tzInfo.$2.formatCustomGlobal('HH:mm'),
|
||||
).fontSize(12),
|
||||
Text(tzInfo.$1.formatOffsetLocal()).fontSize(12),
|
||||
],
|
||||
).padding(top: 2);
|
||||
} catch (e) {
|
||||
return Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.alarm,
|
||||
size: 17,
|
||||
fill: 1,
|
||||
).padding(right: 2),
|
||||
Text('timezoneNotFound'.tr()).fontSize(12),
|
||||
],
|
||||
).padding(top: 2);
|
||||
}
|
||||
}(),
|
||||
Row(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Icon(Symbols.stairs, size: 17, fill: 1).padding(right: 2),
|
||||
Text(
|
||||
'levelingProgressLevel'.tr(
|
||||
args: [data.profile.level.toString()],
|
||||
),
|
||||
).fontSize(12),
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message:
|
||||
'${(data.profile.levelingProgress * 100).toStringAsFixed(2)}%',
|
||||
child: LinearProgressIndicator(
|
||||
value: data.profile.levelingProgress,
|
||||
stopIndicatorRadius: 0,
|
||||
trackGap: 0,
|
||||
minHeight: 4,
|
||||
).padding(top: 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(top: 2),
|
||||
if (data.badges.isNotEmpty)
|
||||
BadgeList(badges: data.badges).padding(top: 12),
|
||||
ActivityPresenceWidget(
|
||||
uname: uname,
|
||||
isCompact: true,
|
||||
compactPadding: const EdgeInsets.only(top: 12),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
],
|
||||
),
|
||||
error: (err, _) => ResponseErrorWidget(
|
||||
error: err,
|
||||
onRetry: () => ref.invalidate(accountProvider(uname)),
|
||||
),
|
||||
loading: () => SizedBox(
|
||||
width: width,
|
||||
height: width,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: CircularProgressIndicator(),
|
||||
).center(),
|
||||
),
|
||||
);
|
||||
|
||||
return PopupCard(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountPfcRegion extends StatelessWidget {
|
||||
final String? uname;
|
||||
final Widget child;
|
||||
const AccountPfcRegion({super.key, required this.uname, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
child: child,
|
||||
onTapDown: (details) {
|
||||
if (uname != null) {
|
||||
showAccountProfileCard(
|
||||
context,
|
||||
uname!,
|
||||
offset: details.localPosition,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showAccountProfileCard(
|
||||
BuildContext context,
|
||||
String uname, {
|
||||
Offset? offset,
|
||||
}) async {
|
||||
await showPopupCard<void>(
|
||||
offset: offset ?? Offset.zero,
|
||||
context: context,
|
||||
builder: (context) => AccountProfileCard(uname: uname),
|
||||
alignment: Alignment.center,
|
||||
dimBackground: true,
|
||||
);
|
||||
}
|
||||
102
lib/accounts/accounts_widgets/account/account_picker.dart
Normal file
102
lib/accounts/accounts_widgets/account/account_picker.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_models/account.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/drive/drive_widgets/cloud_files.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'account_picker.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<SnAccount>> searchAccounts(Ref ref, {required String query}) async {
|
||||
if (query.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final response = await apiClient.get(
|
||||
'/pass/accounts/search',
|
||||
queryParameters: {'query': query},
|
||||
);
|
||||
|
||||
return response.data!
|
||||
.map((json) => SnAccount.fromJson(json))
|
||||
.cast<SnAccount>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
class AccountPickerSheet extends HookConsumerWidget {
|
||||
const AccountPickerSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final searchController = useTextEditingController();
|
||||
final debounceTimer = useState<Timer?>(null);
|
||||
|
||||
void onSearchChanged(String query) {
|
||||
debounceTimer.value?.cancel();
|
||||
debounceTimer.value = Timer(const Duration(milliseconds: 300), () {
|
||||
ref.read(searchAccountsProvider(query: query));
|
||||
});
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: MediaQuery.of(context).viewInsets,
|
||||
height: MediaQuery.of(context).size.height * 0.6,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: TextField(
|
||||
controller: searchController,
|
||||
onChanged: onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'searchAccounts'.tr(),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 18,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
autofocus: true,
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final searchResult = ref.watch(
|
||||
searchAccountsProvider(query: searchController.text),
|
||||
);
|
||||
|
||||
return searchResult.when(
|
||||
data: (accounts) => ListView.builder(
|
||||
itemCount: accounts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final account = accounts[index];
|
||||
return ListTile(
|
||||
leading: ProfilePictureWidget(
|
||||
file: account.profile.picture,
|
||||
),
|
||||
title: Text(account.nick),
|
||||
subtitle: Text('@${account.name}'),
|
||||
onTap: () => Navigator.of(context).pop(account),
|
||||
);
|
||||
},
|
||||
),
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/accounts/accounts_widgets/account/account_picker.g.dart
Normal file
85
lib/accounts/accounts_widgets/account/account_picker.g.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_picker.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(searchAccounts)
|
||||
final searchAccountsProvider = SearchAccountsFamily._();
|
||||
|
||||
final class SearchAccountsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnAccount>>,
|
||||
List<SnAccount>,
|
||||
FutureOr<List<SnAccount>>
|
||||
>
|
||||
with $FutureModifier<List<SnAccount>>, $FutureProvider<List<SnAccount>> {
|
||||
SearchAccountsProvider._({
|
||||
required SearchAccountsFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'searchAccountsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$searchAccountsHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'searchAccountsProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SnAccount>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SnAccount>> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return searchAccounts(ref, query: argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is SearchAccountsProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$searchAccountsHash() => r'3b4aa4d7970a1e406c1a0a1dfac2c686e05bc533';
|
||||
|
||||
final class SearchAccountsFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<List<SnAccount>>, String> {
|
||||
SearchAccountsFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'searchAccountsProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
SearchAccountsProvider call({required String query}) =>
|
||||
SearchAccountsProvider._(argument: query, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'searchAccountsProvider';
|
||||
}
|
||||
618
lib/accounts/accounts_widgets/account/activity_presence.dart
Normal file
618
lib/accounts/accounts_widgets/account/activity_presence.dart
Normal file
@@ -0,0 +1,618 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
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/core/models/activity.dart';
|
||||
import 'package:island/activity/activity_rpc.dart';
|
||||
import 'package:island/core/widgets/content/image.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
part 'activity_presence.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<Map<String, String>?> discordAssets(
|
||||
Ref ref,
|
||||
SnPresenceActivity activity,
|
||||
) async {
|
||||
final hasDiscordSmall =
|
||||
activity.smallImage != null &&
|
||||
activity.smallImage!.startsWith('discord:');
|
||||
final hasDiscordLarge =
|
||||
activity.largeImage != null &&
|
||||
activity.largeImage!.startsWith('discord:');
|
||||
|
||||
if (hasDiscordSmall || hasDiscordLarge) {
|
||||
final dio = Dio();
|
||||
final response = await dio.get(
|
||||
'https://discordapp.com/api/oauth2/applications/${activity.manualId}/assets',
|
||||
);
|
||||
final data = response.data as List<dynamic>;
|
||||
return {
|
||||
for (final item in data) item['name'] as String: item['id'] as String,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<String?> discordAssetsUrl(
|
||||
Ref ref,
|
||||
SnPresenceActivity activity,
|
||||
String key,
|
||||
) async {
|
||||
final assets = await ref.watch(discordAssetsProvider(activity).future);
|
||||
if (assets != null && assets.containsKey(key)) {
|
||||
final assetId = assets[key]!;
|
||||
return 'https://cdn.discordapp.com/app-assets/${activity.manualId}/$assetId.png';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const kPresenceActivityTypes = [
|
||||
'unknown',
|
||||
'presenceTypeGaming',
|
||||
'presenceTypeMusic',
|
||||
'presenceTypeWorkout',
|
||||
];
|
||||
|
||||
const kPresenceActivityIcons = <IconData>[
|
||||
Symbols.question_mark_rounded,
|
||||
Symbols.play_arrow_rounded,
|
||||
Symbols.music_note_rounded,
|
||||
Symbols.running_with_errors,
|
||||
];
|
||||
|
||||
class ActivityPresenceWidget extends StatefulWidget {
|
||||
final String uname;
|
||||
final bool isCompact;
|
||||
final EdgeInsets compactPadding;
|
||||
|
||||
const ActivityPresenceWidget({
|
||||
super.key,
|
||||
required this.uname,
|
||||
this.isCompact = false,
|
||||
this.compactPadding = EdgeInsets.zero,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ActivityPresenceWidget> createState() => _ActivityPresenceWidgetState();
|
||||
}
|
||||
|
||||
class _ActivityPresenceWidgetState extends State<ActivityPresenceWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _progressController;
|
||||
late Animation<double> _progressAnimation;
|
||||
double _startProgress = 0.0;
|
||||
double _endProgress = 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_progressController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 1),
|
||||
);
|
||||
_progressAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 0.0,
|
||||
).animate(_progressController);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_progressController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Widget> _buildImages(WidgetRef ref, SnPresenceActivity activity) {
|
||||
final List<Widget> images = [];
|
||||
|
||||
if (activity.largeImage != null) {
|
||||
if (activity.largeImage!.startsWith('discord:')) {
|
||||
final key = activity.largeImage!.substring('discord:'.length);
|
||||
final urlAsync = ref.watch(discordAssetsUrlProvider(activity, key));
|
||||
images.add(
|
||||
urlAsync.when(
|
||||
data: (url) => url != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
width: 64,
|
||||
height: 64,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
loading: () => const SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
images.add(
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: UniversalImage(
|
||||
uri: activity.largeImage!,
|
||||
width: 64,
|
||||
height: 64,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.smallImage != null) {
|
||||
if (activity.smallImage!.startsWith('discord:')) {
|
||||
final key = activity.smallImage!.substring('discord:'.length);
|
||||
final urlAsync = ref.watch(discordAssetsUrlProvider(activity, key));
|
||||
images.add(
|
||||
urlAsync.when(
|
||||
data: (url) => url != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
width: 32,
|
||||
height: 32,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
loading: () => const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
images.add(
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: UniversalImage(
|
||||
uri: activity.smallImage!,
|
||||
width: 32,
|
||||
height: 32,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer(
|
||||
builder: (BuildContext context, WidgetRef ref, Widget? child) {
|
||||
final activitiesAsync = ref.watch(
|
||||
presenceActivitiesProvider(widget.uname),
|
||||
);
|
||||
|
||||
if (widget.isCompact) {
|
||||
return activitiesAsync.when(
|
||||
data: (activities) {
|
||||
if (activities.isEmpty) return const SizedBox.shrink();
|
||||
final activity = activities.first;
|
||||
return Padding(
|
||||
padding: widget.compactPadding,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (activity.largeImage != null)
|
||||
activity.largeImage!.startsWith('discord:')
|
||||
? ref
|
||||
.watch(
|
||||
discordAssetsUrlProvider(
|
||||
activity,
|
||||
activity.largeImage!.substring(
|
||||
'discord:'.length,
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(
|
||||
data: (url) => url != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
4,
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: url,
|
||||
width: 32,
|
||||
height: 32,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
loading: () => const SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
error: (error, stack) =>
|
||||
const SizedBox.shrink(),
|
||||
)
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: UniversalImage(
|
||||
uri: activity.largeImage!,
|
||||
width: 32,
|
||||
height: 32,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
(activity.title?.isEmpty ?? true)
|
||||
? 'unknown'.tr()
|
||||
: activity.title!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).fontSize(13),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
kPresenceActivityTypes[activity.type],
|
||||
).tr().fontSize(11),
|
||||
Icon(
|
||||
kPresenceActivityIcons[activity.type],
|
||||
size: 15,
|
||||
fill: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
StreamBuilder(
|
||||
stream: Stream.periodic(const Duration(seconds: 1)),
|
||||
builder: (context, snapshot) {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (activity.manualId == 'spotify' &&
|
||||
activity.meta != null) {
|
||||
final meta = activity.meta as Map<String, dynamic>;
|
||||
final progressMs = meta['progress_ms'] as int? ?? 0;
|
||||
final durationMs =
|
||||
meta['track_duration_ms'] as int? ?? 1;
|
||||
final elapsed = now
|
||||
.difference(activity.createdAt)
|
||||
.inMilliseconds;
|
||||
final currentProgressMs =
|
||||
(progressMs + elapsed) % durationMs;
|
||||
final progressValue = currentProgressMs / durationMs;
|
||||
if (progressValue != _endProgress) {
|
||||
_startProgress = _endProgress;
|
||||
_endProgress = progressValue;
|
||||
_progressAnimation = Tween<double>(
|
||||
begin: _startProgress,
|
||||
end: _endProgress,
|
||||
).animate(_progressController);
|
||||
_progressController.forward(from: 0.0);
|
||||
}
|
||||
return AnimatedBuilder(
|
||||
animation: _progressAnimation,
|
||||
builder: (context, child) {
|
||||
final animatedValue = _progressAnimation.value;
|
||||
final animatedProgressMs =
|
||||
(animatedValue * durationMs).toInt();
|
||||
final currentMin = animatedProgressMs ~/ 60000;
|
||||
final currentSec =
|
||||
(animatedProgressMs % 60000) ~/ 1000;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
spacing: 2,
|
||||
children: [
|
||||
Text(
|
||||
'${currentMin.toString().padLeft(2, '0')}:${currentSec.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: LinearProgressIndicator(
|
||||
value: animatedValue,
|
||||
backgroundColor: Colors.grey.shade300,
|
||||
stopIndicatorColor: Colors.green,
|
||||
trackGap: 0,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.green,
|
||||
),
|
||||
),
|
||||
).padding(top: 2),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
final duration = now.difference(activity.createdAt);
|
||||
final hours = duration.inHours.toString().padLeft(
|
||||
2,
|
||||
'0',
|
||||
);
|
||||
final minutes = (duration.inMinutes % 60)
|
||||
.toString()
|
||||
.padLeft(2, '0');
|
||||
final seconds = (duration.inSeconds % 60)
|
||||
.toString()
|
||||
.padLeft(2, '0');
|
||||
return Text(
|
||||
'$hours:$minutes:$seconds',
|
||||
).textColor(Colors.green).fontSize(12);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
return activitiesAsync.when(
|
||||
data: (activities) => Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
'activities',
|
||||
).tr().bold().padding(horizontal: 16, vertical: 4),
|
||||
if (activities.isEmpty)
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
const Icon(Symbols.inbox, size: 16),
|
||||
Text('dataEmpty').tr().fontSize(13),
|
||||
],
|
||||
).opacity(0.75).padding(horizontal: 16, bottom: 8),
|
||||
...activities.map((activity) {
|
||||
final images = _buildImages(ref, activity);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(
|
||||
color: Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
child: ListTile(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (images.isNotEmpty)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
spacing: 8,
|
||||
children: images,
|
||||
).padding(vertical: 4),
|
||||
Row(
|
||||
spacing: 2,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
(activity.title?.isEmpty ?? true)
|
||||
? 'unknown'.tr()
|
||||
: activity.title!,
|
||||
),
|
||||
),
|
||||
if (activity.titleUrl != null &&
|
||||
activity.titleUrl!.isNotEmpty)
|
||||
IconButton(
|
||||
visualDensity: const VisualDensity(
|
||||
vertical: -4,
|
||||
),
|
||||
onPressed: () {
|
||||
launchUrlString(activity.titleUrl!);
|
||||
},
|
||||
icon: const Icon(Symbols.launch_rounded),
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 28,
|
||||
maxHeight: 28,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
kPresenceActivityTypes[activity.type],
|
||||
).tr(),
|
||||
Icon(
|
||||
kPresenceActivityIcons[activity.type],
|
||||
size: 16,
|
||||
fill: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (activity.manualId == 'spotify' &&
|
||||
activity.meta != null)
|
||||
StreamBuilder(
|
||||
stream: Stream.periodic(
|
||||
const Duration(seconds: 1),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
final now = DateTime.now();
|
||||
final meta =
|
||||
activity.meta as Map<String, dynamic>;
|
||||
final progressMs =
|
||||
meta['progress_ms'] as int? ?? 0;
|
||||
final durationMs =
|
||||
meta['track_duration_ms'] as int? ?? 1;
|
||||
final elapsed = now
|
||||
.difference(activity.createdAt)
|
||||
.inMilliseconds;
|
||||
final currentProgressMs =
|
||||
(progressMs + elapsed) % durationMs;
|
||||
final progressValue =
|
||||
currentProgressMs / durationMs;
|
||||
if (progressValue != _endProgress) {
|
||||
_startProgress = _endProgress;
|
||||
_endProgress = progressValue;
|
||||
_progressAnimation = Tween<double>(
|
||||
begin: _startProgress,
|
||||
end: _endProgress,
|
||||
).animate(_progressController);
|
||||
_progressController.forward(from: 0.0);
|
||||
}
|
||||
return AnimatedBuilder(
|
||||
animation: _progressAnimation,
|
||||
builder: (context, child) {
|
||||
final animatedValue =
|
||||
_progressAnimation.value;
|
||||
final animatedProgressMs =
|
||||
(animatedValue * durationMs)
|
||||
.toInt();
|
||||
final currentMin =
|
||||
animatedProgressMs ~/ 60000;
|
||||
final currentSec =
|
||||
(animatedProgressMs % 60000) ~/
|
||||
1000;
|
||||
final totalMin = durationMs ~/ 60000;
|
||||
final totalSec =
|
||||
(durationMs % 60000) ~/ 1000;
|
||||
return Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: animatedValue,
|
||||
backgroundColor:
|
||||
Colors.grey.shade300,
|
||||
trackGap: 0,
|
||||
stopIndicatorColor: Colors.green,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(
|
||||
Colors.green,
|
||||
),
|
||||
).padding(top: 3),
|
||||
Text(
|
||||
'${currentMin.toString().padLeft(2, '0')}:${currentSec.toString().padLeft(2, '0')} / ${totalMin.toString().padLeft(2, '0')}:${totalSec.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
StreamBuilder(
|
||||
stream: Stream.periodic(
|
||||
const Duration(seconds: 1),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
final now = DateTime.now();
|
||||
|
||||
final duration = now.difference(
|
||||
activity.createdAt,
|
||||
);
|
||||
final hours = duration.inHours
|
||||
.toString()
|
||||
.padLeft(2, '0');
|
||||
final minutes = (duration.inMinutes % 60)
|
||||
.toString()
|
||||
.padLeft(2, '0');
|
||||
final seconds = (duration.inSeconds % 60)
|
||||
.toString()
|
||||
.padLeft(2, '0');
|
||||
return Text(
|
||||
'$hours:$minutes:$seconds',
|
||||
).textColor(Colors.green);
|
||||
},
|
||||
),
|
||||
if (activity.subtitle?.isNotEmpty ?? false)
|
||||
Row(
|
||||
spacing: 2,
|
||||
children: [
|
||||
Flexible(child: Text(activity.subtitle!)),
|
||||
if (activity.titleUrl != null &&
|
||||
activity.titleUrl!.isNotEmpty)
|
||||
IconButton(
|
||||
visualDensity: const VisualDensity(
|
||||
vertical: -4,
|
||||
),
|
||||
onPressed: () {
|
||||
launchUrlString(activity.titleUrl!);
|
||||
},
|
||||
icon: const Icon(
|
||||
Symbols.launch_rounded,
|
||||
),
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 28,
|
||||
maxHeight: 28,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (activity.caption?.isNotEmpty ?? false)
|
||||
Text(activity.caption!),
|
||||
],
|
||||
),
|
||||
),
|
||||
).padding(horizontal: 8),
|
||||
if (activity.manualId == 'spotify')
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 24,
|
||||
child: Tooltip(
|
||||
message: 'Listening on Spotify',
|
||||
child: Image.asset(
|
||||
'assets/images/oidc/spotify.png',
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
).padding(horizontal: 8, top: 8, bottom: 16),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) =>
|
||||
Center(child: Text('Error loading activities: $error')),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
164
lib/accounts/accounts_widgets/account/activity_presence.g.dart
Normal file
164
lib/accounts/accounts_widgets/account/activity_presence.g.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'activity_presence.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(discordAssets)
|
||||
final discordAssetsProvider = DiscordAssetsFamily._();
|
||||
|
||||
final class DiscordAssetsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<Map<String, String>?>,
|
||||
Map<String, String>?,
|
||||
FutureOr<Map<String, String>?>
|
||||
>
|
||||
with
|
||||
$FutureModifier<Map<String, String>?>,
|
||||
$FutureProvider<Map<String, String>?> {
|
||||
DiscordAssetsProvider._({
|
||||
required DiscordAssetsFamily super.from,
|
||||
required SnPresenceActivity super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'discordAssetsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$discordAssetsHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'discordAssetsProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<Map<String, String>?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<Map<String, String>?> create(Ref ref) {
|
||||
final argument = this.argument as SnPresenceActivity;
|
||||
return discordAssets(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is DiscordAssetsProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$discordAssetsHash() => r'3ef8465188059de96cf2ac9660ed3d88910443bf';
|
||||
|
||||
final class DiscordAssetsFamily extends $Family
|
||||
with
|
||||
$FunctionalFamilyOverride<
|
||||
FutureOr<Map<String, String>?>,
|
||||
SnPresenceActivity
|
||||
> {
|
||||
DiscordAssetsFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'discordAssetsProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
DiscordAssetsProvider call(SnPresenceActivity activity) =>
|
||||
DiscordAssetsProvider._(argument: activity, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'discordAssetsProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(discordAssetsUrl)
|
||||
final discordAssetsUrlProvider = DiscordAssetsUrlFamily._();
|
||||
|
||||
final class DiscordAssetsUrlProvider
|
||||
extends $FunctionalProvider<AsyncValue<String?>, String?, FutureOr<String?>>
|
||||
with $FutureModifier<String?>, $FutureProvider<String?> {
|
||||
DiscordAssetsUrlProvider._({
|
||||
required DiscordAssetsUrlFamily super.from,
|
||||
required (SnPresenceActivity, String) super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'discordAssetsUrlProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$discordAssetsUrlHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'discordAssetsUrlProvider'
|
||||
''
|
||||
'$argument';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<String?> $createElement($ProviderPointer pointer) =>
|
||||
$FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<String?> create(Ref ref) {
|
||||
final argument = this.argument as (SnPresenceActivity, String);
|
||||
return discordAssetsUrl(ref, argument.$1, argument.$2);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is DiscordAssetsUrlProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$discordAssetsUrlHash() => r'a32f9333c3fb4d50ff88a54a6b8b72fbf5ba3ea1';
|
||||
|
||||
final class DiscordAssetsUrlFamily extends $Family
|
||||
with
|
||||
$FunctionalFamilyOverride<
|
||||
FutureOr<String?>,
|
||||
(SnPresenceActivity, String)
|
||||
> {
|
||||
DiscordAssetsUrlFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'discordAssetsUrlProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
DiscordAssetsUrlProvider call(SnPresenceActivity activity, String key) =>
|
||||
DiscordAssetsUrlProvider._(argument: (activity, key), from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'discordAssetsUrlProvider';
|
||||
}
|
||||
46
lib/accounts/accounts_widgets/account/badge.dart
Normal file
46
lib/accounts/accounts_widgets/account/badge.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/accounts/accounts_models/account.dart';
|
||||
import 'package:island/accounts/accounts_models/badge.dart';
|
||||
|
||||
class BadgeList extends StatelessWidget {
|
||||
final List<SnAccountBadge> badges;
|
||||
const BadgeList({super.key, required this.badges});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: badges.map((badge) => BadgeItem(badge: badge)).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BadgeItem extends StatelessWidget {
|
||||
final SnAccountBadge badge;
|
||||
const BadgeItem({super.key, required this.badge});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final template = kBadgeTemplates[badge.type];
|
||||
final name = badge.label ?? template?.name.tr() ?? 'unknown'.tr();
|
||||
final description = badge.caption ?? template?.description.tr() ?? '';
|
||||
|
||||
return Tooltip(
|
||||
message: '$name\n$description',
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: (template?.color ?? Colors.blue).withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
template?.icon ?? Icons.stars,
|
||||
color: template?.color ?? Colors.blue,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
178
lib/accounts/accounts_widgets/account/event_calendar.dart
Normal file
178
lib/accounts/accounts_widgets/account/event_calendar.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_models/account.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/event_details_widget.dart';
|
||||
import 'package:island/core/models/activity.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
|
||||
/// A reusable widget for displaying an event calendar with event details
|
||||
/// This can be used in various places throughout the app
|
||||
class EventCalendarWidget extends HookConsumerWidget {
|
||||
/// The list of calendar entries to display
|
||||
final AsyncValue<List<SnEventCalendarEntry>> events;
|
||||
|
||||
/// Initial date to focus on
|
||||
final DateTime? initialDate;
|
||||
|
||||
/// Whether to show the event details below the calendar
|
||||
final bool showEventDetails;
|
||||
|
||||
/// Whether to constrain the width of the calendar
|
||||
final bool constrainWidth;
|
||||
|
||||
/// Maximum width constraint when constrainWidth is true
|
||||
final double maxWidth;
|
||||
|
||||
/// Callback when a day is selected
|
||||
final void Function(DateTime)? onDaySelected;
|
||||
|
||||
/// Callback when the focused month changes
|
||||
final void Function(int year, int month)? onMonthChanged;
|
||||
|
||||
const EventCalendarWidget({
|
||||
super.key,
|
||||
required this.events,
|
||||
this.initialDate,
|
||||
this.showEventDetails = true,
|
||||
this.constrainWidth = false,
|
||||
this.maxWidth = 480,
|
||||
this.onDaySelected,
|
||||
this.onMonthChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedMonth = useState(initialDate?.month ?? DateTime.now().month);
|
||||
final selectedYear = useState(initialDate?.year ?? DateTime.now().year);
|
||||
final selectedDay = useState(initialDate ?? DateTime.now());
|
||||
|
||||
final content = Column(
|
||||
children: [
|
||||
TableCalendar(
|
||||
locale: EasyLocalization.of(context)!.locale.toString(),
|
||||
firstDay: DateTime.now().add(Duration(days: -3650)),
|
||||
lastDay: DateTime.now().add(Duration(days: 3650)),
|
||||
focusedDay: DateTime.utc(
|
||||
selectedYear.value,
|
||||
selectedMonth.value,
|
||||
selectedDay.value.day,
|
||||
),
|
||||
weekNumbersVisible: false,
|
||||
calendarFormat: CalendarFormat.month,
|
||||
selectedDayPredicate: (day) {
|
||||
return isSameDay(selectedDay.value, day);
|
||||
},
|
||||
onDaySelected: (value, _) {
|
||||
selectedDay.value = value;
|
||||
onDaySelected?.call(value);
|
||||
},
|
||||
onPageChanged: (focusedDay) {
|
||||
selectedMonth.value = focusedDay.month;
|
||||
selectedYear.value = focusedDay.year;
|
||||
onMonthChanged?.call(focusedDay.year, focusedDay.month);
|
||||
},
|
||||
eventLoader: (day) {
|
||||
return events.value
|
||||
?.where((e) => isSameDay(e.date, day))
|
||||
.expand((e) => [...e.statuses, e.checkInResult])
|
||||
.where((e) => e != null)
|
||||
.toList() ??
|
||||
[];
|
||||
},
|
||||
calendarBuilders: CalendarBuilders(
|
||||
dowBuilder: (context, day) {
|
||||
final text = DateFormat.EEEEE().format(day);
|
||||
return Center(child: Text(text));
|
||||
},
|
||||
markerBuilder: (context, day, events) {
|
||||
final checkInResult = events
|
||||
.whereType<SnCheckInResult>()
|
||||
.firstOrNull;
|
||||
final statuses = events.whereType<SnAccountStatus>().toList();
|
||||
|
||||
final textColor = isSameDay(selectedDay.value, day)
|
||||
? Colors.white
|
||||
: isSameDay(DateTime.now(), day)
|
||||
? Colors.white
|
||||
: Theme.of(context).colorScheme.onSurface;
|
||||
|
||||
final shadow =
|
||||
isSameDay(selectedDay.value, day) ||
|
||||
isSameDay(DateTime.now(), day)
|
||||
? [
|
||||
Shadow(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
),
|
||||
]
|
||||
: null;
|
||||
|
||||
if (checkInResult != null) {
|
||||
return Positioned(
|
||||
top: 32,
|
||||
child: Row(
|
||||
spacing: 2,
|
||||
children: [
|
||||
Text(
|
||||
'checkInResultT${checkInResult.level}'.tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: textColor,
|
||||
shadows: shadow,
|
||||
),
|
||||
),
|
||||
if (statuses.isNotEmpty) ...[
|
||||
Icon(
|
||||
switch (statuses.first.attitude) {
|
||||
0 => Symbols.sentiment_satisfied,
|
||||
2 => Symbols.sentiment_dissatisfied,
|
||||
_ => Symbols.sentiment_neutral,
|
||||
},
|
||||
size: 12,
|
||||
color: textColor,
|
||||
shadows: shadow,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
if (showEventDetails) ...[
|
||||
const Divider(height: 1).padding(top: 8),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final event = events.value
|
||||
?.where((e) => isSameDay(e.date, selectedDay.value))
|
||||
.firstOrNull;
|
||||
return EventDetailsWidget(
|
||||
selectedDay: selectedDay.value,
|
||||
event: event,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if (constrainWidth) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Card(margin: EdgeInsets.all(16), child: content),
|
||||
).center();
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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/accounts/account/profile.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_nameplate.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/event_calendar.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/fortune_graph.dart';
|
||||
import 'package:island/accounts/event_calendar.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
/// A reusable content widget for event calendar that can be used in screens or sheets
|
||||
/// This widget manages the calendar state and displays the calendar and fortune graph
|
||||
class EventCalendarContent extends HookConsumerWidget {
|
||||
/// Username to fetch calendar for, null means current user ('me')
|
||||
final String name;
|
||||
|
||||
/// Whether this is being displayed in a sheet (affects layout)
|
||||
final bool isSheet;
|
||||
|
||||
const EventCalendarContent({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.isSheet = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Get the current date
|
||||
final now = DateTime.now();
|
||||
|
||||
// Create the query for the current month
|
||||
final query = useState(
|
||||
EventCalendarQuery(uname: name, year: now.year, month: now.month),
|
||||
);
|
||||
|
||||
// Watch the event calendar data
|
||||
final events = ref.watch(eventCalendarProvider(query.value));
|
||||
final user = ref.watch(accountProvider(name));
|
||||
|
||||
// Track the selected day for synchronizing between widgets
|
||||
final selectedDay = useState(now);
|
||||
|
||||
void onMonthChanged(int year, int month) {
|
||||
query.value = EventCalendarQuery(
|
||||
uname: query.value.uname,
|
||||
year: year,
|
||||
month: month,
|
||||
);
|
||||
}
|
||||
|
||||
// Function to handle day selection for synchronizing between widgets
|
||||
void onDaySelected(DateTime day) {
|
||||
selectedDay.value = day;
|
||||
}
|
||||
|
||||
if (isSheet) {
|
||||
// Sheet layout - simplified, no app bar, scrollable content
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Use the reusable EventCalendarWidget
|
||||
EventCalendarWidget(
|
||||
events: events,
|
||||
initialDate: now,
|
||||
showEventDetails: true,
|
||||
onMonthChanged: onMonthChanged,
|
||||
onDaySelected: onDaySelected,
|
||||
),
|
||||
|
||||
// Add the fortune graph widget
|
||||
const Divider(height: 1),
|
||||
FortuneGraphWidget(
|
||||
events: events,
|
||||
onPointSelected: onDaySelected,
|
||||
).padding(horizontal: 8, vertical: 4),
|
||||
|
||||
// Show user profile if viewing someone else's calendar
|
||||
if (name != 'me' && user.value != null)
|
||||
AccountNameplate(name: name),
|
||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Screen layout - with responsive design
|
||||
return SingleChildScrollView(
|
||||
child: MediaQuery.of(context).size.width > 480
|
||||
? ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 480),
|
||||
child: Column(
|
||||
children: [
|
||||
Card(
|
||||
margin: EdgeInsets.only(left: 16, right: 16, top: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Use the reusable EventCalendarWidget
|
||||
EventCalendarWidget(
|
||||
events: events,
|
||||
initialDate: now,
|
||||
showEventDetails: true,
|
||||
onMonthChanged: onMonthChanged,
|
||||
onDaySelected: onDaySelected,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Add the fortune graph widget
|
||||
FortuneGraphWidget(
|
||||
events: events,
|
||||
constrainWidth: true,
|
||||
onPointSelected: onDaySelected,
|
||||
),
|
||||
|
||||
// Show user profile if viewing someone else's calendar
|
||||
if (name != 'me' && user.value != null)
|
||||
AccountNameplate(name: name),
|
||||
],
|
||||
),
|
||||
).center()
|
||||
: Column(
|
||||
children: [
|
||||
// Use the reusable EventCalendarWidget
|
||||
EventCalendarWidget(
|
||||
events: events,
|
||||
initialDate: now,
|
||||
showEventDetails: true,
|
||||
onMonthChanged: onMonthChanged,
|
||||
onDaySelected: onDaySelected,
|
||||
),
|
||||
|
||||
// Add the fortune graph widget
|
||||
const Divider(height: 1),
|
||||
FortuneGraphWidget(
|
||||
events: events,
|
||||
onPointSelected: onDaySelected,
|
||||
).padding(horizontal: 8, vertical: 4),
|
||||
|
||||
// Show user profile if viewing someone else's calendar
|
||||
if (name != 'me' && user.value != null)
|
||||
AccountNameplate(name: name),
|
||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
106
lib/accounts/accounts_widgets/account/event_details_widget.dart
Normal file
106
lib/accounts/accounts_widgets/account/event_details_widget.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/core/models/activity.dart';
|
||||
import 'package:island/core/services/time.dart';
|
||||
import 'package:island/core/utils/activity_utils.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class EventDetailsWidget extends StatelessWidget {
|
||||
final DateTime selectedDay;
|
||||
final SnEventCalendarEntry? event;
|
||||
|
||||
const EventDetailsWidget({
|
||||
super.key,
|
||||
required this.selectedDay,
|
||||
required this.event,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(DateFormat.EEEE().format(selectedDay))
|
||||
.fontSize(16)
|
||||
.bold()
|
||||
.textColor(Theme.of(context).colorScheme.onSecondaryContainer),
|
||||
Text(DateFormat.yMd().format(selectedDay))
|
||||
.fontSize(12)
|
||||
.textColor(Theme.of(context).colorScheme.onSecondaryContainer),
|
||||
const Gap(16),
|
||||
if (event?.checkInResult != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'checkInResultLevel${event!.checkInResult!.level}',
|
||||
).tr().fontSize(16).bold(),
|
||||
for (final tip in event!.checkInResult!.tips)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.circle,
|
||||
size: 12,
|
||||
fill: 1,
|
||||
).padding(top: 4, right: 4),
|
||||
Icon(
|
||||
tip.isPositive ? Symbols.thumb_up : Symbols.thumb_down,
|
||||
size: 14,
|
||||
).padding(top: 2.5),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [Text(tip.title).bold(), Text(tip.content)],
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(top: 8),
|
||||
if (event!.statuses.isNotEmpty) ...[
|
||||
const Gap(16),
|
||||
Text('statusLabel').tr().fontSize(16).bold(),
|
||||
],
|
||||
for (final status in event!.statuses) ...[
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Icon(switch (status.attitude) {
|
||||
0 => Symbols.sentiment_satisfied,
|
||||
2 => Symbols.sentiment_dissatisfied,
|
||||
_ => Symbols.sentiment_neutral,
|
||||
}),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if ((getActivityTitle(status.label, status.meta) ??
|
||||
status.label)
|
||||
.isNotEmpty)
|
||||
Text(
|
||||
getActivityTitle(status.label, status.meta) ??
|
||||
status.label,
|
||||
),
|
||||
if (getActivitySubtitle(status.meta) != null)
|
||||
Text(
|
||||
getActivitySubtitle(status.meta)!,
|
||||
).fontSize(11).opacity(0.8),
|
||||
Text(
|
||||
'${status.createdAt.formatSystem()} - ${status.clearedAt?.formatSystem() ?? 'present'.tr()}',
|
||||
).fontSize(11).opacity(0.8),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(vertical: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (event?.checkInResult == null && (event?.statuses.isEmpty ?? true))
|
||||
Text('eventCalendarEmpty').tr(),
|
||||
],
|
||||
).padding(vertical: 24, horizontal: 24);
|
||||
}
|
||||
}
|
||||
291
lib/accounts/accounts_widgets/account/fortune_graph.dart
Normal file
291
lib/accounts/accounts_widgets/account/fortune_graph.dart
Normal file
@@ -0,0 +1,291 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/event_calendar_content.dart';
|
||||
import 'package:island/core/models/activity.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
/// A widget that displays a graph of fortune levels over time
|
||||
/// This can be used alongside the EventCalendarWidget to provide a different visualization
|
||||
class FortuneGraphWidget extends HookConsumerWidget {
|
||||
/// The list of calendar entries to display
|
||||
final AsyncValue<List<SnEventCalendarEntry>> events;
|
||||
|
||||
/// Whether to constrain the width of the graph
|
||||
final bool constrainWidth;
|
||||
|
||||
/// Maximum width constraint when constrainWidth is true
|
||||
final double maxWidth;
|
||||
|
||||
/// Height of the graph
|
||||
final double height;
|
||||
|
||||
/// Callback when a point is selected
|
||||
final void Function(DateTime)? onPointSelected;
|
||||
|
||||
final String? eventCalandarUser;
|
||||
|
||||
final EdgeInsets? margin;
|
||||
|
||||
const FortuneGraphWidget({
|
||||
super.key,
|
||||
required this.events,
|
||||
this.constrainWidth = false,
|
||||
this.maxWidth = double.infinity,
|
||||
this.height = 180,
|
||||
this.onPointSelected,
|
||||
this.eventCalandarUser,
|
||||
this.margin,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Filter events to only include those with check-in results
|
||||
final filteredEvents = events.whenData(
|
||||
(data) =>
|
||||
data
|
||||
.where((event) => event.checkInResult != null)
|
||||
.toList()
|
||||
.cast<SnEventCalendarEntry>()
|
||||
// Sort by date
|
||||
..sort((a, b) => a.date.compareTo(b.date)),
|
||||
);
|
||||
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('fortuneGraph').tr().fontSize(18).bold(),
|
||||
if (eventCalandarUser != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.calendar_month, size: 20),
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => SheetScaffold(
|
||||
titleText: 'eventCalendar'.tr(),
|
||||
child: EventCalendarContent(
|
||||
name: eventCalandarUser!,
|
||||
isSheet: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
).padding(all: 16, bottom: 24),
|
||||
SizedBox(
|
||||
height: height,
|
||||
child: filteredEvents.when(
|
||||
data: (data) {
|
||||
if (data.isEmpty) {
|
||||
return Center(child: Text('noFortuneData').tr());
|
||||
}
|
||||
|
||||
// Create spots for the line chart
|
||||
final spots = data
|
||||
.map(
|
||||
(e) => FlSpot(
|
||||
e.date.millisecondsSinceEpoch.toDouble(),
|
||||
e.checkInResult!.level.toDouble(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Get min and max dates for the x-axis
|
||||
final minDate = data.first.date;
|
||||
final maxDate = data.last.date;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
horizontalInterval: 1,
|
||||
drawVerticalLine: false,
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 30,
|
||||
interval: _calculateDateInterval(minDate, maxDate),
|
||||
getTitlesWidget: (value, meta) {
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(
|
||||
value.toInt(),
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
DateFormat.MMMd().format(date),
|
||||
style: TextStyle(fontSize: 10),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
interval: 1,
|
||||
reservedSize: 40,
|
||||
getTitlesWidget: (value, meta) {
|
||||
final level = value.toInt();
|
||||
if (level < 0 || level > 4) return const SizedBox();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: Text(
|
||||
'checkInResultT$level'.tr(),
|
||||
style: TextStyle(fontSize: 10),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
topTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
borderData: FlBorderData(
|
||||
show: true,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
left: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
),
|
||||
minX: minDate.millisecondsSinceEpoch.toDouble(),
|
||||
maxX: maxDate.millisecondsSinceEpoch.toDouble(),
|
||||
minY: 0,
|
||||
maxY: 4,
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (touchedSpots) {
|
||||
return touchedSpots.map((spot) {
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(
|
||||
spot.x.toInt(),
|
||||
);
|
||||
final level = spot.y.toInt();
|
||||
return LineTooltipItem(
|
||||
'${DateFormat.yMMMd().format(date)}\n',
|
||||
TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'checkInResultLevel$level'.tr(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
),
|
||||
touchCallback:
|
||||
(FlTouchEvent event, LineTouchResponse? response) {
|
||||
if (event is FlTapUpEvent &&
|
||||
response != null &&
|
||||
response.lineBarSpots != null &&
|
||||
response.lineBarSpots!.isNotEmpty) {
|
||||
final spot = response.lineBarSpots!.first;
|
||||
final date = DateTime.fromMillisecondsSinceEpoch(
|
||||
spot.x.toInt(),
|
||||
);
|
||||
onPointSelected?.call(date);
|
||||
}
|
||||
},
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
strokeWidth: 2,
|
||||
strokeColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface,
|
||||
);
|
||||
},
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Center(child: Text('Error: $error')),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (constrainWidth) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: Card(margin: margin ?? EdgeInsets.all(16), child: content),
|
||||
).center();
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/// Calculate an appropriate interval for date labels based on the date range
|
||||
double _calculateDateInterval(DateTime minDate, DateTime maxDate) {
|
||||
final difference = maxDate.difference(minDate).inDays;
|
||||
|
||||
// If less than 7 days, show all days
|
||||
if (difference <= 7) {
|
||||
return 24 * 60 * 60 * 1000; // One day in milliseconds
|
||||
}
|
||||
|
||||
// If less than a month, show every 3 days
|
||||
if (difference <= 30) {
|
||||
return 3 * 24 * 60 * 60 * 1000; // Three days in milliseconds
|
||||
}
|
||||
|
||||
// If less than 3 months, show weekly
|
||||
if (difference <= 90) {
|
||||
return 7 * 24 * 60 * 60 * 1000; // One week in milliseconds
|
||||
}
|
||||
|
||||
// Otherwise show every 2 weeks
|
||||
return 14 * 24 * 60 * 60 * 1000; // Two weeks in milliseconds
|
||||
}
|
||||
}
|
||||
321
lib/accounts/accounts_widgets/account/friends_overview.dart
Normal file
321
lib/accounts/accounts_widgets/account/friends_overview.dart
Normal file
@@ -0,0 +1,321 @@
|
||||
import 'dart:async';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:island/accounts/accounts_models/account.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/account_pfc.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/core/config.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
part 'friends_overview.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<SnFriendOverviewItem>> friendsOverview(Ref ref) async {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final resp = await apiClient.get('/pass/friends/overview');
|
||||
return (resp.data as List<dynamic>)
|
||||
.map((e) => SnFriendOverviewItem.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
|
||||
class FriendsOverviewWidget extends HookConsumerWidget {
|
||||
final bool hideWhenEmpty;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
const FriendsOverviewWidget({
|
||||
super.key,
|
||||
this.hideWhenEmpty = false,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Set up periodic refresh every minute
|
||||
useEffect(() {
|
||||
final timer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
ref.invalidate(friendsOverviewProvider);
|
||||
});
|
||||
|
||||
return () => timer.cancel(); // Cleanup when widget is disposed
|
||||
}, const []);
|
||||
|
||||
final friendsOverviewAsync = ref.watch(friendsOverviewProvider);
|
||||
|
||||
return friendsOverviewAsync.when(
|
||||
data: (friends) {
|
||||
// Filter for online friends
|
||||
final onlineFriends = friends
|
||||
.where((friend) => friend.status.isOnline)
|
||||
.toList();
|
||||
|
||||
if (onlineFriends.isEmpty && hideWhenEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final card = Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.group,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'friendsOnline'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
if (onlineFriends.isEmpty)
|
||||
Container(
|
||||
height: 80,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'No friends online',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: onlineFriends.length,
|
||||
itemBuilder: (context, index) {
|
||||
final friend = onlineFriends[index];
|
||||
return AccountPfcRegion(
|
||||
uname: friend.account.name,
|
||||
child: _FriendTile(friend: friend),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget result = card;
|
||||
if (padding != null) {
|
||||
result = Padding(padding: padding!, child: result);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
loading: () {
|
||||
final card = Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.group,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'friendsOnline'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 12),
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: List.generate(
|
||||
4,
|
||||
(index) => const SkeletonFriendTile(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget result = Skeletonizer(child: card);
|
||||
if (padding != null) {
|
||||
result = Padding(padding: padding!, child: result);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
error: (error, stack) => const SizedBox.shrink(), // Hide on error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SkeletonFriendTile extends StatelessWidget {
|
||||
const SkeletonFriendTile({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 60,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Avatar with online indicator
|
||||
Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
'A',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Online indicator - green dot for skeleton
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(4),
|
||||
// Name placeholder
|
||||
Text(
|
||||
'Friend',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
).center();
|
||||
}
|
||||
}
|
||||
|
||||
class _FriendTile extends ConsumerWidget {
|
||||
final SnFriendOverviewItem friend;
|
||||
|
||||
const _FriendTile({required this.friend});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final serverUrl = ref.watch(serverUrlProvider);
|
||||
|
||||
String? uri;
|
||||
if (friend.account.profile.picture != null) {
|
||||
uri = '$serverUrl/drive/files/${friend.account.profile.picture!.id}';
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: 60,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Avatar with online indicator
|
||||
Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundImage: uri != null
|
||||
? CachedNetworkImageProvider(uri)
|
||||
: null,
|
||||
child: uri == null
|
||||
? Text(
|
||||
friend.account.nick.isNotEmpty
|
||||
? friend.account.nick[0].toUpperCase()
|
||||
: friend.account.name[0].toUpperCase(),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
// Online indicator - show play arrow if user has activities, otherwise green dot
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: friend.activities.isNotEmpty
|
||||
? Colors.blue.withOpacity(0.8)
|
||||
: Colors.green,
|
||||
shape: friend.activities.isNotEmpty
|
||||
? BoxShape.rectangle
|
||||
: BoxShape.circle,
|
||||
borderRadius: friend.activities.isNotEmpty
|
||||
? BorderRadius.circular(4)
|
||||
: null,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.surface,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: friend.activities.isNotEmpty
|
||||
? Icon(
|
||||
Symbols.play_arrow,
|
||||
size: 10,
|
||||
color: Colors.white,
|
||||
fill: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(4),
|
||||
// Name (truncated if too long)
|
||||
Text(
|
||||
friend.account.nick.isNotEmpty
|
||||
? friend.account.nick
|
||||
: friend.account.name,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
).center();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'friends_overview.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(friendsOverview)
|
||||
final friendsOverviewProvider = FriendsOverviewProvider._();
|
||||
|
||||
final class FriendsOverviewProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnFriendOverviewItem>>,
|
||||
List<SnFriendOverviewItem>,
|
||||
FutureOr<List<SnFriendOverviewItem>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SnFriendOverviewItem>>,
|
||||
$FutureProvider<List<SnFriendOverviewItem>> {
|
||||
FriendsOverviewProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'friendsOverviewProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$friendsOverviewHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SnFriendOverviewItem>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SnFriendOverviewItem>> create(Ref ref) {
|
||||
return friendsOverview(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$friendsOverviewHash() => r'5ef86c6849804c97abd3df094f120c7dd5e938db';
|
||||
158
lib/accounts/accounts_widgets/account/leveling_progress.dart
Normal file
158
lib/accounts/accounts_widgets/account/leveling_progress.dart
Normal file
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class LevelingProgressCard extends StatelessWidget {
|
||||
final int level;
|
||||
final int experience;
|
||||
final double progress;
|
||||
final VoidCallback? onTap;
|
||||
final bool isCompact;
|
||||
|
||||
const LevelingProgressCard({
|
||||
super.key,
|
||||
required this.level,
|
||||
required this.experience,
|
||||
required this.progress,
|
||||
this.onTap,
|
||||
this.isCompact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Calculate level stage (1-12, each stage covers 10 levels)
|
||||
int stage = ((level - 1) ~/ 10) + 1;
|
||||
stage = stage.clamp(1, 12); // Ensure stage is within 1-12
|
||||
|
||||
// Define colors for each stage
|
||||
const List<Color> stageColors = [
|
||||
Colors.green,
|
||||
Colors.blue,
|
||||
Colors.teal,
|
||||
Colors.cyan,
|
||||
Colors.indigo,
|
||||
Colors.lime,
|
||||
Colors.yellow,
|
||||
Colors.amber,
|
||||
Colors.orange,
|
||||
Colors.deepOrange,
|
||||
Colors.pink,
|
||||
Colors.red,
|
||||
];
|
||||
|
||||
Color stageColor = stageColors[stage - 1];
|
||||
|
||||
// Compact mode adjustments
|
||||
final double levelFontSize = isCompact ? 14 : 18;
|
||||
final double stageFontSize = isCompact ? 13 : 14;
|
||||
final double experienceFontSize = isCompact ? 12 : 14;
|
||||
final double progressHeight = isCompact ? 6 : 10;
|
||||
final double horizontalPadding = isCompact ? 16 : 20;
|
||||
final double verticalPadding = isCompact ? 12 : 16;
|
||||
final double gapSize = isCompact ? 4 : 8;
|
||||
final double rowSpacing = 12;
|
||||
|
||||
final cardContent = Card(
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
stageColor.withOpacity(0.1),
|
||||
Theme.of(context).colorScheme.surface,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
spacing: rowSpacing,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'levelingProgressLevel'.tr(args: [level.toString()]),
|
||||
style: TextStyle(
|
||||
color: stageColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: levelFontSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'levelingStage$stage'.tr(),
|
||||
style: TextStyle(
|
||||
color: stageColor.withOpacity(0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: stageFontSize,
|
||||
),
|
||||
),
|
||||
if (onTap != null) ...[
|
||||
const Gap(4),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: isCompact ? 10 : 12,
|
||||
color: stageColor.withOpacity(0.7),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Gap(gapSize),
|
||||
Row(
|
||||
spacing: rowSpacing,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: '${(progress * 100).toStringAsFixed(1)}%',
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: progressHeight,
|
||||
value: progress,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerLow.withOpacity(0.75),
|
||||
color: stageColor,
|
||||
stopIndicatorRadius: 0,
|
||||
trackGap: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'levelingProgressExperience'.tr(
|
||||
args: [experience.toString()],
|
||||
),
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withOpacity(0.8),
|
||||
fontSize: experienceFontSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(horizontal: horizontalPadding, vertical: verticalPadding),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return cardContent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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/core/network.dart';
|
||||
import 'package:island/accounts/account/me/settings_connections.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/widgets/content/sheet.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class RestorePurchaseSheet extends HookConsumerWidget {
|
||||
const RestorePurchaseSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedProvider = useState<String?>(null);
|
||||
final orderIdController = useTextEditingController();
|
||||
final isLoading = useState(false);
|
||||
|
||||
final providers = ['afdian'];
|
||||
|
||||
Future<void> restorePurchase() async {
|
||||
if (selectedProvider.value == null ||
|
||||
orderIdController.text.trim().isEmpty) {
|
||||
showErrorAlert('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
await client.post(
|
||||
'/wallet/subscriptions/order/restore/${selectedProvider.value!}',
|
||||
data: {'order_id': orderIdController.text.trim()},
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
showSnackBar('Purchase restored successfully!');
|
||||
}
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'restorePurchase'.tr(),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'restorePurchaseDescription'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
|
||||
// Provider Selection
|
||||
Text(
|
||||
'provider'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Gap(8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedProvider.value,
|
||||
hint: Text('selectProvider'.tr()),
|
||||
isExpanded: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
items: providers.map((provider) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: provider,
|
||||
child: Row(
|
||||
children: [
|
||||
getProviderIcon(
|
||||
provider,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
const Gap(12),
|
||||
Text(getLocalizedProviderName(provider)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
selectedProvider.value = value;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
|
||||
// Order ID Input
|
||||
Text(
|
||||
'orderId'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Gap(8),
|
||||
TextField(
|
||||
controller: orderIdController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'enterOrderId'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
|
||||
// Restore Button
|
||||
FilledButton(
|
||||
onPressed: isLoading.value ? null : restorePurchase,
|
||||
child: isLoading.value
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text('restore'.tr()),
|
||||
),
|
||||
const Gap(16),
|
||||
],
|
||||
).padding(all: 16),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
271
lib/accounts/accounts_widgets/account/status.dart
Normal file
271
lib/accounts/accounts_widgets/account/status.dart
Normal file
@@ -0,0 +1,271 @@
|
||||
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/accounts/account/profile.dart';
|
||||
import 'package:island/accounts/accounts_models/account.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/status_creation.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/accounts/accounts_pod.dart';
|
||||
import 'package:island/core/services/time.dart';
|
||||
import 'package:island/core/utils/activity_utils.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';
|
||||
|
||||
final currentAccountStatusProvider =
|
||||
NotifierProvider<CurrentAccountStatusNotifier, SnAccountStatus?>(
|
||||
CurrentAccountStatusNotifier.new,
|
||||
);
|
||||
|
||||
class CurrentAccountStatusNotifier extends Notifier<SnAccountStatus?> {
|
||||
@override
|
||||
SnAccountStatus? build() {
|
||||
return null;
|
||||
}
|
||||
|
||||
void setStatus(SnAccountStatus status) {
|
||||
state = status;
|
||||
}
|
||||
|
||||
void clearStatus() {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
@riverpod
|
||||
Future<SnAccountStatus?> accountStatus(Ref ref, String uname) async {
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
if (uname == 'me' ||
|
||||
(userInfo.value != null && uname == userInfo.value!.name)) {
|
||||
final local = ref.watch(currentAccountStatusProvider);
|
||||
if (local != null) {
|
||||
return local;
|
||||
}
|
||||
}
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
try {
|
||||
final resp = await apiClient.get('/pass/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));
|
||||
|
||||
final renderPadding =
|
||||
padding ?? EdgeInsets.symmetric(horizontal: 16, vertical: 8);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: userStatus.when(
|
||||
data: (status) => (status?.isCustomized ?? false)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: AccountStatusWidget(
|
||||
uname: uname,
|
||||
padding: renderPadding,
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: renderPadding,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.keyboard_arrow_up),
|
||||
SizedBox(width: 4),
|
||||
Text('Create Status').tr(),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'Tap to set your current activity and let others know what you\'re up to',
|
||||
style: TextStyle(fontSize: 12),
|
||||
).tr().opacity(0.75),
|
||||
],
|
||||
),
|
||||
).opacity(0.85),
|
||||
error: (error, _) => Padding(
|
||||
padding:
|
||||
padding ?? EdgeInsets.symmetric(horizontal: 26, vertical: 12),
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [Icon(Symbols.close), Text('Error: $error')],
|
||||
),
|
||||
).opacity(0.85),
|
||||
loading: () => Padding(
|
||||
padding:
|
||||
padding ?? EdgeInsets.symmetric(horizontal: 26, vertical: 12),
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [Icon(Symbols.more_vert), Text('loading').tr()],
|
||||
),
|
||||
).opacity(0.85),
|
||||
),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
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 userInfo = ref.watch(userInfoProvider);
|
||||
final localStatus = ref.watch(currentAccountStatusProvider);
|
||||
final status =
|
||||
(uname == 'me' ||
|
||||
(userInfo.value != null &&
|
||||
uname == userInfo.value!.name &&
|
||||
localStatus != null))
|
||||
? AsyncValue.data(localStatus)
|
||||
: ref.watch(accountStatusProvider(uname));
|
||||
final account = ref.watch(accountProvider(uname));
|
||||
|
||||
return Padding(
|
||||
padding: padding ?? EdgeInsets.symmetric(horizontal: 27, vertical: 4),
|
||||
child: Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
if (status.value?.isOnline ?? false)
|
||||
Icon(
|
||||
Symbols.circle,
|
||||
fill: 1,
|
||||
color: Colors.green,
|
||||
size: 16,
|
||||
).padding(right: 4)
|
||||
else
|
||||
Icon(
|
||||
Symbols.circle,
|
||||
color: Colors.grey,
|
||||
size: 16,
|
||||
).padding(right: 4),
|
||||
if (status.value?.isCustomized ?? false)
|
||||
Flexible(
|
||||
child: GestureDetector(
|
||||
onLongPress: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Activity Details'),
|
||||
content: buildActivityDetails(status.value),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text('Close'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Tooltip(
|
||||
richMessage: getActivityFullMessage(status.value),
|
||||
child: Text(
|
||||
getActivityTitle(status.value?.label, status.value?.meta) ??
|
||||
'unknown'.tr(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
child: Text(
|
||||
(status.value?.label ?? 'offline').toLowerCase(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).tr(),
|
||||
),
|
||||
if (getActivitySubtitle(status.value?.meta) != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
getActivitySubtitle(status.value?.meta)!,
|
||||
).opacity(0.75),
|
||||
)
|
||||
else if (!(status.value?.isOnline ?? false) &&
|
||||
account.value?.profile.lastSeenAt != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
account.value!.profile.lastSeenAt!.formatRelative(context),
|
||||
).opacity(0.75),
|
||||
),
|
||||
],
|
||||
),
|
||||
).opacity((status.value?.isCustomized ?? false) ? 1 : 0.85);
|
||||
}
|
||||
}
|
||||
|
||||
class AccountStatusLabel extends StatelessWidget {
|
||||
final SnAccountStatus status;
|
||||
final TextStyle? style;
|
||||
final int maxLines;
|
||||
final TextOverflow overflow;
|
||||
|
||||
const AccountStatusLabel({
|
||||
super.key,
|
||||
required this.status,
|
||||
this.style,
|
||||
this.maxLines = 1,
|
||||
this.overflow = TextOverflow.ellipsis,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.circle,
|
||||
fill: 1,
|
||||
color: status.isOnline ? Colors.green : Colors.grey,
|
||||
size: 14,
|
||||
).padding(right: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
status.label,
|
||||
style: style,
|
||||
maxLines: maxLines,
|
||||
overflow: overflow,
|
||||
).fontSize(13),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/accounts/accounts_widgets/account/status.g.dart
Normal file
85
lib/accounts/accounts_widgets/account/status.g.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'status.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(accountStatus)
|
||||
final accountStatusProvider = AccountStatusFamily._();
|
||||
|
||||
final class AccountStatusProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<SnAccountStatus?>,
|
||||
SnAccountStatus?,
|
||||
FutureOr<SnAccountStatus?>
|
||||
>
|
||||
with $FutureModifier<SnAccountStatus?>, $FutureProvider<SnAccountStatus?> {
|
||||
AccountStatusProvider._({
|
||||
required AccountStatusFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'accountStatusProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountStatusHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'accountStatusProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<SnAccountStatus?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<SnAccountStatus?> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return accountStatus(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is AccountStatusProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$accountStatusHash() => r'4cac809808e6f1345dab06dc32d759cfcea13315';
|
||||
|
||||
final class AccountStatusFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<SnAccountStatus?>, String> {
|
||||
AccountStatusFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'accountStatusProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
AccountStatusProvider call(String uname) =>
|
||||
AccountStatusProvider._(argument: uname, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'accountStatusProvider';
|
||||
}
|
||||
217
lib/accounts/accounts_widgets/account/status_creation.dart
Normal file
217
lib/accounts/accounts_widgets/account/status_creation.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
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/accounts/accounts_models/account.dart';
|
||||
import 'package:island/accounts/accounts_widgets/account/status.dart';
|
||||
import 'package:island/core/network.dart';
|
||||
import 'package:island/accounts/accounts_pod.dart';
|
||||
import 'package:island/shared/widgets/alert.dart';
|
||||
import 'package:island/core/widgets/content/sheet.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('/pass/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(
|
||||
'/pass/accounts/me/statuses',
|
||||
data: {
|
||||
'attitude': attitude.value,
|
||||
'is_invisible': isInvisible.value,
|
||||
'is_not_disturb': isNotDisturb.value,
|
||||
'cleared_at': clearedAt.value?.toUtc().toIso8601String(),
|
||||
if (labelController.text.isNotEmpty) 'label': labelController.text,
|
||||
},
|
||||
options: Options(method: initialStatus == null ? 'POST' : 'PATCH'),
|
||||
);
|
||||
if (user.value != null) {
|
||||
ref.invalidate(accountStatusProvider(user.value!.name));
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
titleText: initialStatus == null
|
||||
? 'statusCreate'.tr()
|
||||
: 'statusUpdate'.tr(),
|
||||
actions: [
|
||||
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)),
|
||||
),
|
||||
],
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1398
lib/accounts/accounts_widgets/account/stellar_program_tab.dart
Normal file
1398
lib/accounts/accounts_widgets/account/stellar_program_tab.dart
Normal file
File diff suppressed because it is too large
Load Diff
301
lib/accounts/accounts_widgets/account/stellar_program_tab.g.dart
Normal file
301
lib/accounts/accounts_widgets/account/stellar_program_tab.g.dart
Normal file
@@ -0,0 +1,301 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'stellar_program_tab.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(accountStellarSubscription)
|
||||
final accountStellarSubscriptionProvider =
|
||||
AccountStellarSubscriptionProvider._();
|
||||
|
||||
final class AccountStellarSubscriptionProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<SnWalletSubscription?>,
|
||||
SnWalletSubscription?,
|
||||
FutureOr<SnWalletSubscription?>
|
||||
>
|
||||
with
|
||||
$FutureModifier<SnWalletSubscription?>,
|
||||
$FutureProvider<SnWalletSubscription?> {
|
||||
AccountStellarSubscriptionProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'accountStellarSubscriptionProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountStellarSubscriptionHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<SnWalletSubscription?> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<SnWalletSubscription?> create(Ref ref) {
|
||||
return accountStellarSubscription(ref);
|
||||
}
|
||||
}
|
||||
|
||||
String _$accountStellarSubscriptionHash() =>
|
||||
r'fd0aa9b7110e5d0ba68d8a57bd0e4dc191586e3b';
|
||||
|
||||
@ProviderFor(accountSentGifts)
|
||||
final accountSentGiftsProvider = AccountSentGiftsFamily._();
|
||||
|
||||
final class AccountSentGiftsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnWalletGift>>,
|
||||
List<SnWalletGift>,
|
||||
FutureOr<List<SnWalletGift>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SnWalletGift>>,
|
||||
$FutureProvider<List<SnWalletGift>> {
|
||||
AccountSentGiftsProvider._({
|
||||
required AccountSentGiftsFamily super.from,
|
||||
required ({int offset, int take}) super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'accountSentGiftsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountSentGiftsHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'accountSentGiftsProvider'
|
||||
''
|
||||
'$argument';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SnWalletGift>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SnWalletGift>> create(Ref ref) {
|
||||
final argument = this.argument as ({int offset, int take});
|
||||
return accountSentGifts(ref, offset: argument.offset, take: argument.take);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is AccountSentGiftsProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$accountSentGiftsHash() => r'9fa99729b9efa1a74695645ee1418677b5e63027';
|
||||
|
||||
final class AccountSentGiftsFamily extends $Family
|
||||
with
|
||||
$FunctionalFamilyOverride<
|
||||
FutureOr<List<SnWalletGift>>,
|
||||
({int offset, int take})
|
||||
> {
|
||||
AccountSentGiftsFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'accountSentGiftsProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
AccountSentGiftsProvider call({int offset = 0, int take = 20}) =>
|
||||
AccountSentGiftsProvider._(
|
||||
argument: (offset: offset, take: take),
|
||||
from: this,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => r'accountSentGiftsProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(accountReceivedGifts)
|
||||
final accountReceivedGiftsProvider = AccountReceivedGiftsFamily._();
|
||||
|
||||
final class AccountReceivedGiftsProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<List<SnWalletGift>>,
|
||||
List<SnWalletGift>,
|
||||
FutureOr<List<SnWalletGift>>
|
||||
>
|
||||
with
|
||||
$FutureModifier<List<SnWalletGift>>,
|
||||
$FutureProvider<List<SnWalletGift>> {
|
||||
AccountReceivedGiftsProvider._({
|
||||
required AccountReceivedGiftsFamily super.from,
|
||||
required ({int offset, int take}) super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'accountReceivedGiftsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountReceivedGiftsHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'accountReceivedGiftsProvider'
|
||||
''
|
||||
'$argument';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<List<SnWalletGift>> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<List<SnWalletGift>> create(Ref ref) {
|
||||
final argument = this.argument as ({int offset, int take});
|
||||
return accountReceivedGifts(
|
||||
ref,
|
||||
offset: argument.offset,
|
||||
take: argument.take,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is AccountReceivedGiftsProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$accountReceivedGiftsHash() =>
|
||||
r'b9e9ad5e8de8916f881ceeca7f2032f344c5c58b';
|
||||
|
||||
final class AccountReceivedGiftsFamily extends $Family
|
||||
with
|
||||
$FunctionalFamilyOverride<
|
||||
FutureOr<List<SnWalletGift>>,
|
||||
({int offset, int take})
|
||||
> {
|
||||
AccountReceivedGiftsFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'accountReceivedGiftsProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
AccountReceivedGiftsProvider call({int offset = 0, int take = 20}) =>
|
||||
AccountReceivedGiftsProvider._(
|
||||
argument: (offset: offset, take: take),
|
||||
from: this,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => r'accountReceivedGiftsProvider';
|
||||
}
|
||||
|
||||
@ProviderFor(accountGift)
|
||||
final accountGiftProvider = AccountGiftFamily._();
|
||||
|
||||
final class AccountGiftProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
AsyncValue<SnWalletGift>,
|
||||
SnWalletGift,
|
||||
FutureOr<SnWalletGift>
|
||||
>
|
||||
with $FutureModifier<SnWalletGift>, $FutureProvider<SnWalletGift> {
|
||||
AccountGiftProvider._({
|
||||
required AccountGiftFamily super.from,
|
||||
required String super.argument,
|
||||
}) : super(
|
||||
retry: null,
|
||||
name: r'accountGiftProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$accountGiftHash();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return r'accountGiftProvider'
|
||||
''
|
||||
'($argument)';
|
||||
}
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$FutureProviderElement<SnWalletGift> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $FutureProviderElement(pointer);
|
||||
|
||||
@override
|
||||
FutureOr<SnWalletGift> create(Ref ref) {
|
||||
final argument = this.argument as String;
|
||||
return accountGift(ref, argument);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is AccountGiftProvider && other.argument == argument;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return argument.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
String _$accountGiftHash() => r'78890be44865accadeabdc26a96447bb3e841a5d';
|
||||
|
||||
final class AccountGiftFamily extends $Family
|
||||
with $FunctionalFamilyOverride<FutureOr<SnWalletGift>, String> {
|
||||
AccountGiftFamily._()
|
||||
: super(
|
||||
retry: null,
|
||||
name: r'accountGiftProvider',
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
isAutoDispose: true,
|
||||
);
|
||||
|
||||
AccountGiftProvider call(String giftId) =>
|
||||
AccountGiftProvider._(argument: giftId, from: this);
|
||||
|
||||
@override
|
||||
String toString() => r'accountGiftProvider';
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'user_list_item.dart';
|
||||
163
lib/accounts/accounts_widgets/activitypub/actor_list_item.dart
Normal file
163
lib/accounts/accounts_widgets/activitypub/actor_list_item.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/core/models/activitypub.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class ApActorListItem extends StatelessWidget {
|
||||
final SnActivityPubActor actor;
|
||||
final bool isFollowing;
|
||||
final bool isLoading;
|
||||
final VoidCallback? onFollow;
|
||||
final VoidCallback? onUnfollow;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ApActorListItem({
|
||||
super.key,
|
||||
required this.actor,
|
||||
this.isFollowing = false,
|
||||
this.isLoading = false,
|
||||
this.onFollow,
|
||||
this.onUnfollow,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
String _getDisplayName() {
|
||||
if (actor.displayName?.isNotEmpty ?? false) {
|
||||
return actor.displayName!;
|
||||
}
|
||||
if (actor.username?.isNotEmpty ?? false) {
|
||||
return actor.username!;
|
||||
}
|
||||
return actor.id.split('@').lastOrNull ?? 'Unknown';
|
||||
}
|
||||
|
||||
String _getUsername() {
|
||||
if (actor.username?.isNotEmpty ?? false) {
|
||||
return '${actor.username}@${actor.instance.domain}';
|
||||
}
|
||||
return actor.id;
|
||||
}
|
||||
|
||||
String _getInstanceDomain() {
|
||||
final parts = actor.id.split('@');
|
||||
if (parts.length >= 3) {
|
||||
return parts[2];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
bool _isLocal() {
|
||||
// For now, assume all searched actors are remote
|
||||
// This could be determined by checking if the domain matches local instance
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayName = _getDisplayName();
|
||||
final username = _getUsername();
|
||||
final instanceDomain = _getInstanceDomain();
|
||||
final isLocal = _isLocal();
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 16, right: 12),
|
||||
leading: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundImage: actor.avatarUrl != null
|
||||
? CachedNetworkImageProvider(actor.avatarUrl!)
|
||||
: null,
|
||||
radius: 24,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: actor.avatarUrl == null
|
||||
? Icon(
|
||||
Symbols.person,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
if (!isLocal)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: CircleAvatar(
|
||||
backgroundImage: actor.instance.iconUrl != null
|
||||
? CachedNetworkImageProvider(actor.instance.iconUrl!)
|
||||
: null,
|
||||
radius: 8,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: actor.instance.iconUrl == null
|
||||
? Icon(
|
||||
Symbols.public,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(child: Text(displayName)),
|
||||
if (!isLocal && instanceDomain.isNotEmpty) const SizedBox(width: 6),
|
||||
if (!isLocal && instanceDomain.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
instanceDomain,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(username),
|
||||
if (actor.summary?.isNotEmpty ?? false)
|
||||
Expanded(
|
||||
child: Text(
|
||||
actor.summary!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
if (actor.type.isNotEmpty) Text(actor.type),
|
||||
],
|
||||
),
|
||||
trailing: isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: isFollowing
|
||||
? OutlinedButton(
|
||||
onPressed: onUnfollow,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(88, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
child: const Text('Unfollow'),
|
||||
)
|
||||
: FilledButton(
|
||||
onPressed: onFollow,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(88, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
child: const Text('Follow'),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
60
lib/accounts/accounts_widgets/activitypub/actor_profile.dart
Normal file
60
lib/accounts/accounts_widgets/activitypub/actor_profile.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/core/models/activitypub.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
class ActorPictureWidget extends StatelessWidget {
|
||||
final SnActivityPubActor actor;
|
||||
final double radius;
|
||||
|
||||
const ActorPictureWidget({super.key, required this.actor, this.radius = 16});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final avatarUrl = actor.avatarUrl;
|
||||
if (avatarUrl == null) {
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Icon(
|
||||
Symbols.person,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundImage: CachedNetworkImageProvider(avatarUrl),
|
||||
radius: radius,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: avatarUrl.isNotEmpty
|
||||
? null
|
||||
: Icon(
|
||||
Symbols.person,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: CircleAvatar(
|
||||
backgroundImage: actor.instance.iconUrl != null
|
||||
? CachedNetworkImageProvider(actor.instance.iconUrl!)
|
||||
: null,
|
||||
radius: radius * 0.4,
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
child: actor.instance.iconUrl == null
|
||||
? Icon(
|
||||
Symbols.public,
|
||||
size: radius * 0.6,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
128
lib/accounts/accounts_widgets/activitypub/user_list_item.dart
Normal file
128
lib/accounts/accounts_widgets/activitypub/user_list_item.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:island/core/models/activitypub.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
|
||||
class ActivityPubUserListItem extends StatelessWidget {
|
||||
final SnActivityPubUser user;
|
||||
final bool isFollowing;
|
||||
final bool isLoading;
|
||||
final VoidCallback? onFollow;
|
||||
final VoidCallback? onUnfollow;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ActivityPubUserListItem({
|
||||
super.key,
|
||||
required this.user,
|
||||
this.isFollowing = false,
|
||||
this.isLoading = false,
|
||||
this.onFollow,
|
||||
this.onUnfollow,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.only(left: 16, right: 12),
|
||||
leading: Stack(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundImage: CachedNetworkImageProvider(user.avatarUrl),
|
||||
radius: 24,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
if (!user.isLocal)
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Symbols.public,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Flexible(child: Text(user.displayName)),
|
||||
if (!user.isLocal) const SizedBox(width: 6),
|
||||
if (!user.isLocal)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
user.instanceDomain,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('@${user.username}'),
|
||||
if (user.bio.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
user.bio,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Followed ${RelativeTime(context).format(user.followedAt)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: isFollowing
|
||||
? OutlinedButton(
|
||||
onPressed: onUnfollow,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(88, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
child: const Text('Unfollow'),
|
||||
)
|
||||
: FilledButton(
|
||||
onPressed: onFollow,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(88, 36),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
child: const Text('Follow'),
|
||||
),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user