🎨 Use feature based folder structure

This commit is contained in:
2026-02-06 00:37:02 +08:00
parent 62a3ea26e3
commit 862e3b451b
539 changed files with 8406 additions and 5056 deletions

View File

@@ -0,0 +1,28 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/accounts_models/abuse_report.dart';
import 'package:island/core/network.dart';
final abuseReportServiceProvider = Provider<AbuseReportService>((ref) {
return AbuseReportService(ref);
});
class AbuseReportService {
final Ref ref;
AbuseReportService(this.ref);
Future<SnAbuseReport> getReport(String id) async {
final response = await ref
.read(apiClientProvider)
.get('/pass/safety/reports/me/$id');
return SnAbuseReport.fromJson(response.data);
}
Future<List<SnAbuseReport>> getReports() async {
final response = await ref
.read(apiClientProvider)
.get('/pass/safety/reports/me');
return (response.data as List)
.map((json) => SnAbuseReport.fromJson(json))
.toList();
}
}

View File

@@ -0,0 +1,164 @@
import 'dart:async';
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/pagination/pagination.dart';
import 'package:island/core/services/time.dart';
import 'package:island/shared/widgets/pagination_list.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'credits.g.dart';
@riverpod
Future<double> socialCredits(Ref ref) async {
final client = ref.watch(apiClientProvider);
final response = await client.get('/pass/accounts/me/credits');
if (response.statusCode != 200) {
throw Exception('Failed to load social credits');
}
return response.data?.toDouble() ?? 0.0;
}
final socialCreditHistoryNotifierProvider = AsyncNotifierProvider.autoDispose(
SocialCreditHistoryNotifier.new,
);
class SocialCreditHistoryNotifier
extends AsyncNotifier<PaginationState<SnSocialCreditRecord>>
with AsyncPaginationController<SnSocialCreditRecord> {
static const int pageSize = 20;
@override
FutureOr<PaginationState<SnSocialCreditRecord>> build() async {
final items = await fetch();
return PaginationState(
items: items,
isLoading: false,
isReloading: false,
totalCount: totalCount,
hasMore: hasMore,
cursor: cursor,
);
}
@override
Future<List<SnSocialCreditRecord>> fetch() async {
final client = ref.read(apiClientProvider);
final queryParams = {'offset': fetchedCount.toString(), 'take': pageSize};
final response = await client.get(
'/pass/accounts/me/credits/history',
queryParameters: queryParams,
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final records = response.data
.map((json) => SnSocialCreditRecord.fromJson(json))
.cast<SnSocialCreditRecord>()
.toList();
return records;
}
}
class SocialCreditsTab extends HookConsumerWidget {
const SocialCreditsTab({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final socialCredits = ref.watch(socialCreditsProvider);
return Column(
children: [
const Gap(8),
Card(
margin: const EdgeInsets.only(left: 16, right: 16, top: 8),
child: socialCredits
.when(
data: (credits) => Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
credits < 100
? 'socialCreditsLevelPoor'.tr()
: credits < 150
? 'socialCreditsLevelNormal'.tr()
: credits < 200
? 'socialCreditsLevelGood'.tr()
: 'socialCreditsLevelExcellent'.tr(),
).tr().bold().fontSize(20),
Text('${credits.toStringAsFixed(2)} pts').fontSize(14),
const Gap(8),
LinearProgressIndicator(value: credits / 200),
],
),
Positioned(
right: 0,
top: 0,
child: IconButton(
onPressed: () {},
icon: const Icon(Symbols.info),
tooltip: 'socialCreditsDescription'.tr(),
),
),
],
),
error: (_, _) => Text('Error loading credits'),
loading: () => const LinearProgressIndicator(),
)
.padding(horizontal: 20, vertical: 16),
),
Expanded(
child: PaginationList(
padding: EdgeInsets.zero,
provider: socialCreditHistoryNotifierProvider,
notifier: socialCreditHistoryNotifierProvider.notifier,
itemBuilder: (context, idx, record) {
final isExpired =
record.expiredAt != null &&
record.expiredAt!.isBefore(DateTime.now());
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(
record.reason,
style: isExpired
? TextStyle(
decoration: TextDecoration.lineThrough,
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.8),
)
: null,
),
subtitle: Row(
spacing: 4,
children: [
Text(record.createdAt.formatSystem()),
Text('to'),
if (record.expiredAt != null)
Text(record.expiredAt!.formatSystem()),
],
),
trailing: Text(
record.delta > 0 ? '+${record.delta}' : '${record.delta}',
style: TextStyle(
color: record.delta > 0 ? Colors.green : Colors.red,
),
),
);
},
),
),
],
);
}
}

View File

@@ -0,0 +1,43 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'credits.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(socialCredits)
final socialCreditsProvider = SocialCreditsProvider._();
final class SocialCreditsProvider
extends $FunctionalProvider<AsyncValue<double>, double, FutureOr<double>>
with $FutureModifier<double>, $FutureProvider<double> {
SocialCreditsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'socialCreditsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$socialCreditsHash();
@$internal
@override
$FutureProviderElement<double> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<double> create(Ref ref) {
return socialCredits(ref);
}
}
String _$socialCreditsHash() => r'a0284583e94bc97285c689ac2bc018536932da69';

View File

@@ -0,0 +1,290 @@
import 'dart:async';
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/accounts/accounts_widgets/account/leveling_progress.dart';
import 'package:island/accounts/accounts_widgets/account/stellar_program_tab.dart';
import 'package:island/core/network.dart';
import 'package:island/pagination/pagination.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/accounts/account/credits.dart';
import 'package:island/core/services/time.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:island/shared/widgets/pagination_list.dart';
import 'package:styled_widget/styled_widget.dart';
final levelingHistoryNotifierProvider =
AsyncNotifierProvider.autoDispose<
LevelingHistoryNotifier,
PaginationState<SnExperienceRecord>
>(LevelingHistoryNotifier.new);
class LevelingHistoryNotifier
extends AsyncNotifier<PaginationState<SnExperienceRecord>>
with AsyncPaginationController<SnExperienceRecord> {
static const int pageSize = 20;
@override
FutureOr<PaginationState<SnExperienceRecord>> build() async {
final items = await fetch();
return PaginationState(
items: items,
isLoading: false,
isReloading: false,
totalCount: totalCount,
hasMore: hasMore,
cursor: cursor,
);
}
@override
Future<List<SnExperienceRecord>> fetch() async {
final client = ref.read(apiClientProvider);
final queryParams = {'offset': fetchedCount.toString(), 'take': pageSize};
final response = await client.get(
'/pass/accounts/me/leveling',
queryParameters: queryParams,
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final List<SnExperienceRecord> records = response.data
.map((json) => SnExperienceRecord.fromJson(json))
.cast<SnExperienceRecord>()
.toList();
return records;
}
}
class LevelingScreen extends HookConsumerWidget {
const LevelingScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userInfoProvider);
if (user.value == null) {
return AppScaffold(
appBar: AppBar(title: Text('levelingProgress'.tr())),
body: const Center(child: CircularProgressIndicator()),
);
}
return DefaultTabController(
length: 3,
child: AppScaffold(
appBar: AppBar(
title: Text('levelingProgress'.tr()),
bottom: TabBar(
tabs: [
Tab(
child: Text(
'leveling'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'socialCredits'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
Tab(
child: Text(
'stellarProgram'.tr(),
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor!,
),
),
),
],
),
),
body: TabBarView(
children: [
_buildLevelingTab(context, ref, user.value!),
const SocialCreditsTab(),
const StellarProgramTab(),
],
),
),
);
}
Widget _buildLevelingTab(
BuildContext context,
WidgetRef ref,
SnAccount user,
) {
final currentLevel = user.profile.level;
final currentExp = user.profile.experience;
final progress = user.profile.levelingProgress;
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomScrollView(
slivers: [
const SliverGap(20),
// Current Progress Card
SliverToBoxAdapter(
child: LevelingProgressCard(
level: currentLevel,
experience: currentExp,
progress: progress,
),
),
const SliverGap(24),
// Level Stairs Graph
SliverToBoxAdapter(
child: Text(
'levelProgress'.tr(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SliverGap(16),
SliverToBoxAdapter(
child: Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'${'levelingProgressLevel'.tr(args: [currentLevel.toString()])} / 120',
textAlign: TextAlign.start,
style: Theme.of(context).textTheme.bodySmall,
),
const Gap(8),
LinearProgressIndicator(
value: currentLevel / 120,
minHeight: 10,
stopIndicatorRadius: 0,
trackGap: 0,
color: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(32),
),
],
).padding(horizontal: 16, top: 16, bottom: 12),
),
),
const SliverGap(16),
// Leveling History
SliverToBoxAdapter(
child: Text(
'levelingHistory'.tr(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SliverGap(8),
PaginationList(
provider: levelingHistoryNotifierProvider,
notifier: levelingHistoryNotifierProvider.notifier,
isRefreshable: false,
isSliver: true,
itemBuilder: (context, idx, record) => ListTile(
title: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(record.reason),
Row(
spacing: 4,
children: [
Text(
record.createdAt.formatRelative(context),
).fontSize(13),
Text('·').fontSize(13).bold(),
Text(record.createdAt.formatSystem()).fontSize(13),
],
).opacity(0.8),
],
),
subtitle: Row(
spacing: 8,
children: [
Text('${record.delta > 0 ? '+' : ''}${record.delta} EXP'),
if (record.bonusMultiplier != 1.0)
Text('x${record.bonusMultiplier}'),
],
),
minTileHeight: 56,
contentPadding: EdgeInsets.symmetric(horizontal: 4),
),
),
SliverGap(20),
],
),
),
);
}
}
class LevelStairsPainter extends CustomPainter {
final int currentLevel;
final int totalLevels;
final Color primaryColor;
final Color surfaceColor;
final Color onSurfaceColor;
final double stairHeight;
final double stairWidth;
LevelStairsPainter({
required this.currentLevel,
required this.totalLevels,
required this.primaryColor,
required this.surfaceColor,
required this.onSurfaceColor,
required this.stairHeight,
required this.stairWidth,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = surfaceColor.withOpacity(0.2)
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
// Draw connecting lines between stairs
for (int i = 0; i < totalLevels - 1; i++) {
final startX = 20.0 + (i * (stairWidth + 8)) + stairWidth;
final startHeight =
40.0 + (i * 15.0); // Progressive height for current stair
final startY = size.height - (20.0 + startHeight);
final endX = 20.0 + ((i + 1) * (stairWidth + 8));
final endHeight =
40.0 + ((i + 1) * 15.0); // Progressive height for next stair
final endY = size.height - (20.0 + endHeight);
canvas.drawLine(Offset(startX, startY), Offset(endX, endY), paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -0,0 +1,496 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/accounts_widgets/account/account_devices.dart';
import 'package:island/auth/auth_models/auth.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/core/network.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/accounts/account/me/settings_auth_factors.dart';
import 'package:island/accounts/account/me/settings_connections.dart';
import 'package:island/accounts/account/me/settings_contacts.dart';
import 'package:island/auth/captcha.dart';
import 'package:island/auth/login.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/shared/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'account_settings.g.dart';
@riverpod
Future<List<SnAuthFactor>> authFactors(Ref ref) async {
final client = ref.read(apiClientProvider);
final res = await client.get('/pass/accounts/me/factors');
return res.data.map<SnAuthFactor>((e) => SnAuthFactor.fromJson(e)).toList();
}
@riverpod
Future<List<SnContactMethod>> contactMethods(Ref ref) async {
final client = ref.read(apiClientProvider);
final resp = await client.get('/pass/accounts/me/contacts');
return resp.data
.map<SnContactMethod>((e) => SnContactMethod.fromJson(e))
.toList();
}
@riverpod
Future<List<SnAccountConnection>> accountConnections(Ref ref) async {
final client = ref.read(apiClientProvider);
final resp = await client.get('/pass/accounts/me/connections');
return resp.data
.map<SnAccountConnection>((e) => SnAccountConnection.fromJson(e))
.toList();
}
class AccountSettingsScreen extends HookConsumerWidget {
const AccountSettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isDesktop =
!kIsWeb && (Platform.isWindows || Platform.isMacOS || Platform.isLinux);
Future<void> requestAccountDeletion() async {
final confirm = await showConfirmAlert(
'accountDeletionHint'.tr(),
'accountDeletion'.tr(),
isDanger: true,
);
if (!confirm || !context.mounted) return;
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.delete('/pass/accounts/me');
if (context.mounted) {
showSnackBar('accountDeletionSent'.tr());
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> requestResetPassword() async {
final confirm = await showConfirmAlert(
'accountPasswordChangeDescription'.tr(),
'accountPasswordChange'.tr(),
);
if (!confirm || !context.mounted) return;
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
try {
if (context.mounted) showLoadingModal(context);
final userInfo = ref.read(userInfoProvider);
final client = ref.read(apiClientProvider);
await client.post(
'/pass/accounts/recovery/password',
data: {'account': userInfo.value!.name, 'captcha_token': captchaTk},
);
if (context.mounted) {
showSnackBar('accountPasswordChangeSent'.tr());
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
final authFactors = ref.watch(authFactorsProvider);
// Group settings into categories for better organization
final securitySettings = [
ListTile(
minLeadingWidth: 48,
leading: const Icon(Symbols.devices),
title: Text('authSessions').tr(),
subtitle: Text('authSessionsDescription').tr().fontSize(12),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const AccountSessionSheet(),
);
},
),
ExpansionTile(
leading: const Icon(
Symbols.link,
).alignment(Alignment.centerLeft).width(48),
title: Text('accountConnections').tr(),
subtitle: Text('accountConnectionsDescription').tr().fontSize(12),
tilePadding: const EdgeInsets.only(left: 24, right: 17),
children: [
ref
.watch(accountConnectionsProvider)
.when(
data: (connections) => Column(
children: [
for (final connection in connections)
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(
left: 16,
right: 17,
top: 2,
bottom: 4,
),
title: Text(
getLocalizedProviderName(connection.provider),
).tr(),
subtitle: connection.meta['email'] != null
? Text(connection.meta['email'])
: Text(connection.providedIdentifier),
leading: CircleAvatar(
child: getProviderIcon(
connection.provider,
size: 16,
color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
).padding(top: 4),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) =>
AccountConnectionSheet(connection: connection),
).then((value) {
if (value == true) {
ref.invalidate(accountConnectionsProvider);
}
});
},
),
if (connections.isNotEmpty) const Divider(height: 1),
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(
left: 24,
right: 17,
),
title: Text('accountConnectionAdd').tr(),
leading: const Icon(Symbols.add),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) =>
const AccountConnectionNewSheet(),
).then((value) {
if (value == true) {
ref.invalidate(accountConnectionsProvider);
}
});
},
),
],
),
error: (err, _) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(accountConnectionsProvider),
),
loading: () => const ResponseLoadingWidget(),
),
],
),
ExpansionTile(
leading: const Icon(
Symbols.security,
).alignment(Alignment.centerLeft).width(48),
title: Text('accountAuthFactor').tr(),
subtitle: Text('accountAuthFactorDescription').tr().fontSize(12),
tilePadding: const EdgeInsets.only(left: 24, right: 17),
children: [
authFactors.when(
data: (factors) => Column(
children: [
for (final factor in factors)
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(
left: 16,
right: 17,
top: 2,
bottom: 4,
),
title: Text(
kFactorTypes[factor.type]!.$1,
style: factor.enabledAt == null
? TextStyle(decoration: TextDecoration.lineThrough)
: null,
).tr(),
subtitle: Text(
kFactorTypes[factor.type]!.$2,
style: factor.enabledAt == null
? TextStyle(decoration: TextDecoration.lineThrough)
: null,
).tr(),
leading: CircleAvatar(
backgroundColor: factor.enabledAt == null
? Theme.of(context).colorScheme.secondaryContainer
: Theme.of(context).colorScheme.primaryContainer,
child: Icon(kFactorTypes[factor.type]!.$3),
).padding(top: 4),
trailing: const Icon(Symbols.chevron_right),
isThreeLine: true,
onTap: () {
if (factor.type == 0) {
requestResetPassword();
return;
}
showModalBottomSheet(
context: context,
builder: (context) => AuthFactorSheet(factor: factor),
).then((value) {
if (value == true) {
ref.invalidate(authFactorsProvider);
}
});
},
),
if (factors.isNotEmpty) Divider(height: 1),
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(left: 24, right: 17),
title: Text('authFactorNew').tr(),
leading: const Icon(Symbols.add),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => const AuthFactorNewSheet(),
).then((value) {
if (value == true) {
ref.invalidate(authFactorsProvider);
}
});
},
),
],
),
error: (err, _) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(authFactorsProvider),
),
loading: () => ResponseLoadingWidget(),
),
],
),
ExpansionTile(
leading: const Icon(
Symbols.contact_mail,
).alignment(Alignment.centerLeft).width(48),
title: Text('accountContactMethod').tr(),
subtitle: Text('accountContactMethodDescription').tr().fontSize(12),
tilePadding: const EdgeInsets.only(left: 24, right: 17),
children: [
ref
.watch(contactMethodsProvider)
.when(
data: (contacts) => Column(
children: [
for (final contact in contacts)
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(
left: 16,
right: 17,
top: 2,
bottom: 4,
),
title: Text(
contact.content,
style: contact.verifiedAt == null
? TextStyle(
decoration: TextDecoration.lineThrough,
)
: null,
),
subtitle: Text(
contact.type == 0
? 'contactMethodTypeEmail'.tr()
: 'contactMethodTypePhone'.tr(),
style: contact.verifiedAt == null
? TextStyle(
decoration: TextDecoration.lineThrough,
)
: null,
),
leading: CircleAvatar(
backgroundColor: contact.verifiedAt == null
? Theme.of(context).colorScheme.secondaryContainer
: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
contact.type == 0 ? Symbols.mail : Symbols.phone,
),
).padding(top: 4),
trailing: const Icon(Symbols.chevron_right),
isThreeLine: false,
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) =>
ContactMethodSheet(contact: contact),
).then((value) {
if (value == true) {
ref.invalidate(contactMethodsProvider);
}
});
},
),
if (contacts.isNotEmpty) const Divider(height: 1),
ListTile(
minLeadingWidth: 48,
contentPadding: const EdgeInsets.only(
left: 24,
right: 17,
),
title: Text('contactMethodNew').tr(),
leading: const Icon(Symbols.add),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
showModalBottomSheet(
context: context,
builder: (context) => const ContactMethodNewSheet(),
).then((value) {
if (value == true) {
ref.invalidate(contactMethodsProvider);
}
});
},
),
],
),
error: (err, _) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(contactMethodsProvider),
),
loading: () => const ResponseLoadingWidget(),
),
],
),
];
final dangerZoneSettings = [
ListTile(
minLeadingWidth: 48,
title: Text('accountDeletion').tr(),
subtitle: Text('accountDeletionDescription').tr().fontSize(12),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.delete_forever, color: Colors.red),
trailing: const Icon(Symbols.chevron_right),
onTap: requestAccountDeletion,
),
];
// Create a responsive layout based on screen width
Widget buildSettingsList() {
return Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingsSection(
title: 'accountSecurityTitle',
children: securitySettings,
),
_SettingsSection(
title: 'accountDangerZoneTitle',
children: dangerZoneSettings,
),
],
).padding(horizontal: 16);
}
return AppScaffold(
appBar: AppBar(
title: Text('accountSettings').tr(),
actions: isDesktop
? [
IconButton(
icon: const Icon(Symbols.help_outline),
onPressed: () {
// Show help dialog
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('accountSettingsHelp').tr(),
content: Text('accountSettingsHelpContent').tr(),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Close').tr(),
),
],
),
);
},
),
const Gap(8),
]
: null,
),
body: Focus(
autofocus: true,
onKeyEvent: (node, event) {
// Add keyboard shortcuts for desktop
if (isDesktop &&
event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: buildSettingsList(),
),
),
);
}
}
// Helper widget for displaying settings sections with titles
class _SettingsSection extends StatelessWidget {
final String title;
final List<Widget> children;
const _SettingsSection({required this.title, required this.children});
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
title.tr(),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
...children,
const SizedBox(height: 16),
],
),
);
}
}

View File

@@ -0,0 +1,134 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'account_settings.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(authFactors)
final authFactorsProvider = AuthFactorsProvider._();
final class AuthFactorsProvider
extends
$FunctionalProvider<
AsyncValue<List<SnAuthFactor>>,
List<SnAuthFactor>,
FutureOr<List<SnAuthFactor>>
>
with
$FutureModifier<List<SnAuthFactor>>,
$FutureProvider<List<SnAuthFactor>> {
AuthFactorsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'authFactorsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$authFactorsHash();
@$internal
@override
$FutureProviderElement<List<SnAuthFactor>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnAuthFactor>> create(Ref ref) {
return authFactors(ref);
}
}
String _$authFactorsHash() => r'ed87d7dbd421fef0a5620416727c3dc598c97ef5';
@ProviderFor(contactMethods)
final contactMethodsProvider = ContactMethodsProvider._();
final class ContactMethodsProvider
extends
$FunctionalProvider<
AsyncValue<List<SnContactMethod>>,
List<SnContactMethod>,
FutureOr<List<SnContactMethod>>
>
with
$FutureModifier<List<SnContactMethod>>,
$FutureProvider<List<SnContactMethod>> {
ContactMethodsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'contactMethodsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$contactMethodsHash();
@$internal
@override
$FutureProviderElement<List<SnContactMethod>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnContactMethod>> create(Ref ref) {
return contactMethods(ref);
}
}
String _$contactMethodsHash() => r'1d3d03e9ffbf36126236558ead22cb7d88bb9cb2';
@ProviderFor(accountConnections)
final accountConnectionsProvider = AccountConnectionsProvider._();
final class AccountConnectionsProvider
extends
$FunctionalProvider<
AsyncValue<List<SnAccountConnection>>,
List<SnAccountConnection>,
FutureOr<List<SnAccountConnection>>
>
with
$FutureModifier<List<SnAccountConnection>>,
$FutureProvider<List<SnAccountConnection>> {
AccountConnectionsProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'accountConnectionsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$accountConnectionsHash();
@$internal
@override
$FutureProviderElement<List<SnAccountConnection>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnAccountConnection>> create(Ref ref) {
return accountConnections(ref);
}
}
String _$accountConnectionsHash() =>
r'33c10b98962ede6c428d4028c0d5f2f12ff0eb22';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/auth/auth_models/auth.dart';
import 'package:island/core/network.dart';
import 'package:island/auth/login.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:styled_widget/styled_widget.dart';
class AuthFactorSheet extends HookConsumerWidget {
final SnAuthFactor factor;
const AuthFactorSheet({super.key, required this.factor});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> deleteFactor() async {
final confirm = await showConfirmAlert(
'authFactorDeleteHint'.tr(),
'authFactorDelete'.tr(),
isDanger: true,
);
if (!confirm || !context.mounted) return;
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.delete('/pass/accounts/me/factors/${factor.id}');
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> disableFactor() async {
final confirm = await showConfirmAlert(
'authFactorDisableHint'.tr(),
'authFactorDisable'.tr(),
);
if (!confirm || !context.mounted) return;
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.post('/pass/accounts/me/factors/${factor.id}/disable');
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> enableFactor() async {
String? password;
if ([3].contains(factor.type)) {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('authFactorEnable').tr(),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('authFactorEnableHint').tr(),
const SizedBox(height: 16),
OtpTextField(
showCursor: false,
numberOfFields: 6,
obscureText: false,
showFieldAsBox: true,
focusedBorderColor: Theme.of(context).colorScheme.primary,
onSubmit: (String verificationCode) {
password = verificationCode;
},
textStyle: Theme.of(context).textTheme.titleLarge!,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel').tr(),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('confirm').tr(),
),
],
),
);
if (confirmed == false ||
(password?.isEmpty ?? true) ||
!context.mounted) {
return;
}
}
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.post(
'/pass/accounts/me/factors/${factor.id}/enable',
data: jsonEncode(password),
);
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
return SheetScaffold(
titleText: 'authFactor'.tr(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(kFactorTypes[factor.type]!.$3, size: 32),
const Gap(8),
Text(kFactorTypes[factor.type]!.$1).tr(),
const Gap(4),
Text(
kFactorTypes[factor.type]!.$2,
style: Theme.of(context).textTheme.bodySmall,
).tr(),
const Gap(10),
Row(
children: [
if (factor.enabledAt == null)
Badge(
label: Text('authFactorDisabled').tr(),
textColor: Theme.of(context).colorScheme.onSecondary,
backgroundColor: Theme.of(context).colorScheme.secondary,
)
else
Badge(
label: Text('authFactorEnabled').tr(),
textColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
),
],
),
],
).padding(all: 20),
const Divider(height: 1),
if (factor.enabledAt != null)
ListTile(
leading: const Icon(Symbols.disabled_by_default),
title: Text('authFactorDisable').tr(),
onTap: disableFactor,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
)
else
ListTile(
leading: const Icon(Symbols.check_circle),
title: Text('authFactorEnable').tr(),
onTap: enableFactor,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
ListTile(
leading: const Icon(Symbols.delete),
title: Text('authFactorDelete').tr(),
onTap: deleteFactor,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
],
),
);
}
}
class AuthFactorNewSheet extends HookConsumerWidget {
const AuthFactorNewSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final factorType = useState<int>(0);
final secretController = useTextEditingController();
Future<void> addFactor() async {
try {
showLoadingModal(context);
final apiClient = ref.read(apiClientProvider);
final resp = await apiClient.post(
'/pass/accounts/me/factors',
data: {'type': factorType.value, 'secret': secretController.text},
);
final factor = SnAuthFactor.fromJson(resp.data);
if (!context.mounted) return;
hideLoadingModal(context);
if (factor.type == 3) {
showModalBottomSheet(
context: context,
builder: (context) => AuthFactorNewAdditonalSheet(factor: factor),
).then((_) {
if (context.mounted) {
showSnackBar('contactMethodVerificationNeeded'.tr());
}
if (context.mounted) Navigator.pop(context, true);
});
} else {
Navigator.pop(context, true);
}
} catch (err) {
showErrorAlert(err);
if (context.mounted) hideLoadingModal(context);
}
}
final width = math.min(400, MediaQuery.of(context).size.width);
return SheetScaffold(
titleText: 'authFactorNew'.tr(),
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<int>(
value: factorType.value,
decoration: InputDecoration(
labelText: 'authFactor'.tr(),
border: const OutlineInputBorder(),
),
items: kFactorTypes.entries.map((entry) {
return DropdownMenuItem<int>(
value: entry.key,
child: Row(
children: [
Icon(entry.value.$3),
const Gap(8),
Text(entry.value.$1).tr(),
],
),
);
}).toList(),
onChanged: (value) {
if (value != null) {
factorType.value = value;
}
},
),
if ([0].contains(factorType.value))
TextField(
controller: secretController,
decoration: InputDecoration(
prefixIcon: const Icon(Symbols.password_2),
labelText: 'authFactorSecret'.tr(),
hintText: 'authFactorSecretHint'.tr(),
border: const OutlineInputBorder(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
)
else if ([4].contains(factorType.value))
OtpTextField(
showCursor: false,
numberOfFields: 6,
obscureText: false,
showFieldAsBox: true,
focusedBorderColor: Theme.of(context).colorScheme.primary,
fieldWidth: (width / 6) - 10,
keyboardType: TextInputType.number,
onSubmit: (String verificationCode) {
secretController.text = verificationCode;
},
textStyle: Theme.of(context).textTheme.titleLarge!,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(kFactorTypes[factorType.value]!.$2).tr(),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: addFactor,
icon: Icon(Symbols.add),
label: Text('create').tr(),
),
],
),
],
).padding(horizontal: 20, vertical: 24),
);
}
}
class AuthFactorNewAdditonalSheet extends StatelessWidget {
final SnAuthFactor factor;
const AuthFactorNewAdditonalSheet({super.key, required this.factor});
@override
Widget build(BuildContext context) {
final uri = factor.createdResponse?['uri'];
return SheetScaffold(
titleText: 'authFactorAdditional'.tr(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (uri != null) ...[
const SizedBox(height: 16),
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: QrImageView(
data: uri,
version: QrVersions.auto,
size: 200,
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
),
),
const Gap(16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'authFactorQrCodeScan'.tr(),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
] else ...[
const SizedBox(height: 16),
Center(
child: Text(
'authFactorNoQrCode'.tr(),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
const Gap(16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Symbols.check),
label: Text('next'.tr()),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,382 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/auth/auth_models/auth.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:island/accounts/account/me/account_settings.dart';
import 'package:island/core/utils/text.dart';
import 'package:island/core/services/time.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:material_symbols_icons/symbols.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart';
// Helper function to get provider icon and localized name
Widget getProviderIcon(String provider, {double size = 24, Color? color}) {
final providerLower = provider.toLowerCase();
// Check if we have an SVG for this provider
switch (providerLower) {
case 'apple':
case 'microsoft':
case 'google':
case 'github':
case 'discord':
case 'afdian':
case 'steam':
return SvgPicture.asset(
'assets/images/oidc/$providerLower.svg',
width: size,
height: size,
color: color,
);
case 'spotify':
return Image.asset(
'assets/images/oidc/spotify.png',
width: size,
height: size,
color: color,
);
default:
return Icon(Symbols.link, size: size);
}
}
String getLocalizedProviderName(String provider) {
switch (provider.toLowerCase()) {
case 'apple':
return 'accountConnectionProviderApple'.tr();
case 'microsoft':
return 'accountConnectionProviderMicrosoft'.tr();
case 'google':
return 'accountConnectionProviderGoogle'.tr();
case 'github':
return 'accountConnectionProviderGithub'.tr();
case 'discord':
return 'accountConnectionProviderDiscord'.tr();
case 'afdian':
return 'accountConnectionProviderAfdian'.tr();
case 'spotify':
return 'accountConnectionProviderSpotify'.tr();
case 'steam':
return 'accountConnectionProviderSteam'.tr();
default:
return provider;
}
}
class AccountConnectionSheet extends HookConsumerWidget {
final SnAccountConnection connection;
const AccountConnectionSheet({super.key, required this.connection});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> deleteConnection() async {
final confirm = await showConfirmAlert(
'accountConnectionDeleteHint'.tr(),
'accountConnectionDelete'.tr(),
isDanger: true,
);
if (!confirm || !context.mounted) return;
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.delete('/pass/accounts/me/connections/${connection.id}');
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
return SheetScaffold(
titleText: 'accountConnections'.tr(),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
getProviderIcon(
connection.provider,
size: 32,
color: Theme.of(context).colorScheme.onSurface,
),
const Gap(8),
Text(getLocalizedProviderName(connection.provider)).tr(),
const Gap(4),
if (connection.meta.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
for (final meta in connection.meta.entries)
Text(
'${meta.key.replaceAll('_', ' ').capitalizeEachWord()}: ${meta.value}',
style: const TextStyle(fontSize: 12),
),
],
),
Text(
connection.providedIdentifier,
style: Theme.of(context).textTheme.bodySmall,
),
const Gap(8),
Text(
connection.lastUsedAt.formatSystem(),
style: Theme.of(context).textTheme.bodySmall,
).opacity(0.85),
],
).padding(all: 20),
const Divider(height: 1),
ListTile(
leading: const Icon(Symbols.delete),
title: Text('accountConnectionDelete').tr(),
onTap: deleteConnection,
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
),
],
),
),
);
}
}
class AccountConnectionNewSheet extends HookConsumerWidget {
const AccountConnectionNewSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedProvider = useState<String>('apple');
// List of available providers
final providers = [
'apple',
'microsoft',
'google',
'github',
'discord',
'afdian',
'spotify',
'steam',
];
Future<void> addConnection() async {
final client = ref.watch(apiClientProvider);
switch (selectedProvider.value.toLowerCase()) {
case 'apple':
try {
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email],
webAuthenticationOptions: WebAuthenticationOptions(
clientId: 'dev.solsynth.solarpass',
redirectUri: Uri.parse('https://solian.app/auth/callback'),
),
);
if (context.mounted) showLoadingModal(context);
await client.post(
'/pass/auth/connect/apple/mobile',
data: {
'identity_token': credential.identityToken!,
'authorization_code': credential.authorizationCode,
},
);
if (context.mounted) {
showSnackBar('accountConnectionAddSuccess'.tr());
Navigator.pop(context, true);
}
} catch (err) {
if (err is SignInWithAppleAuthorizationException) return;
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
default:
final serverUrl = ref.watch(serverUrlProvider);
final accessToken = ref.watch(tokenProvider);
launchUrlString(
'$serverUrl/pass/auth/login/${selectedProvider.value}?tk=${accessToken!.token}',
);
if (context.mounted) Navigator.pop(context, true);
break;
}
}
return SheetScaffold(
titleText: 'accountConnectionAdd'.tr(),
child: SingleChildScrollView(
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<String>(
value: selectedProvider.value,
decoration: InputDecoration(
prefixIcon: getProviderIcon(
selectedProvider.value,
size: 16,
color: Theme.of(context).colorScheme.onSurface,
).padding(all: 16),
labelText: 'accountConnectionProvider'.tr(),
border: const OutlineInputBorder(),
),
items: providers.map((String provider) {
return DropdownMenuItem<String>(
value: provider,
child: Row(
children: [Text(getLocalizedProviderName(provider)).tr()],
),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
selectedProvider.value = newValue;
}
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text('accountConnectionDescription'.tr()),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: addConnection,
icon: const Icon(Symbols.add),
label: Text('next').tr(),
),
],
),
],
).padding(horizontal: 20, vertical: 24),
),
);
}
}
class AccountConnectionsSheet extends HookConsumerWidget {
const AccountConnectionsSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final connections = ref.watch(accountConnectionsProvider);
return SheetScaffold(
titleText: 'accountConnections'.tr(),
actions: [
IconButton(
icon: const Icon(Symbols.add),
onPressed: () async {
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (context) => const AccountConnectionNewSheet(),
);
if (result == true) {
ref.invalidate(accountConnectionsProvider);
}
},
),
],
child: connections.when(
data: (data) => RefreshIndicator(
onRefresh: () =>
Future.sync(() => ref.invalidate(accountConnectionsProvider)),
child: data.isEmpty
? Center(
child: Text(
'accountConnectionsEmpty'.tr(),
textAlign: TextAlign.center,
).padding(horizontal: 32),
)
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: data.length,
itemBuilder: (context, index) {
final connection = data[index];
return Dismissible(
key: Key('connection-${connection.id}'),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
confirmDismiss: (direction) async {
final confirm = await showConfirmAlert(
'accountConnectionDeleteHint'.tr(),
'accountConnectionDelete'.tr(),
isDanger: true,
);
if (confirm && context.mounted) {
try {
final client = ref.read(apiClientProvider);
await client.delete(
'/pass/accounts/me/connections/${connection.id}',
);
ref.invalidate(accountConnectionsProvider);
return true;
} catch (err) {
showErrorAlert(err);
return false;
}
}
return false;
},
child: ListTile(
leading: getProviderIcon(
connection.provider,
color: Theme.of(context).colorScheme.onSurface,
),
title: Text(
getLocalizedProviderName(connection.provider),
).tr(),
subtitle: connection.meta['email'] != null
? Text(connection.meta['email'])
: Text(connection.providedIdentifier),
trailing: Text(
DateFormat.yMd().format(
connection.lastUsedAt.toLocal(),
),
style: Theme.of(context).textTheme.bodySmall,
),
onTap: () async {
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
builder: (context) =>
AccountConnectionSheet(connection: connection),
);
if (result == true) {
ref.invalidate(accountConnectionsProvider);
}
},
),
);
},
),
),
error: (err, _) => ResponseErrorWidget(
error: err,
onRetry: () => ref.invalidate(accountConnectionsProvider),
),
loading: () => const ResponseLoadingWidget(),
),
);
}
}

View File

@@ -0,0 +1,341 @@
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/core/network.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class ContactMethodSheet extends HookConsumerWidget {
final SnContactMethod contact;
const ContactMethodSheet({super.key, required this.contact});
@override
Widget build(BuildContext context, WidgetRef ref) {
Future<void> deleteContactMethod() async {
final confirm = await showConfirmAlert(
'contactMethodDeleteHint'.tr(),
'contactMethodDelete'.tr(),
isDanger: true,
);
if (!confirm || !context.mounted) return;
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.delete('/pass/accounts/me/contacts/${contact.id}');
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> verifyContactMethod() async {
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.post('/pass/accounts/me/contacts/${contact.id}/verify');
if (context.mounted) {
showSnackBar('contactMethodVerificationSent'.tr());
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> setContactMethodAsPrimary() async {
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.post('/pass/accounts/me/contacts/${contact.id}/primary');
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> makeContactMethodPublic() async {
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.post('/pass/accounts/me/contacts/${contact.id}/public');
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
Future<void> makeContactMethodPrivate() async {
try {
showLoadingModal(context);
final client = ref.read(apiClientProvider);
await client.delete('/pass/accounts/me/contacts/${contact.id}/public');
if (context.mounted) Navigator.pop(context, true);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
return SheetScaffold(
titleText: 'contactMethod'.tr(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(switch (contact.type) {
0 => Symbols.mail,
1 => Symbols.phone,
_ => Symbols.home,
}, size: 32),
const Gap(8),
Text(switch (contact.type) {
0 => 'contactMethodTypeEmail'.tr(),
1 => 'contactMethodTypePhone'.tr(),
_ => 'contactMethodTypeAddress'.tr(),
}),
const Gap(4),
Text(
contact.content,
style: Theme.of(context).textTheme.bodySmall,
),
const Gap(10),
Row(
children: [
if (contact.verifiedAt == null)
Badge(
label: Text('contactMethodUnverified'.tr()),
textColor: Theme.of(context).colorScheme.onSecondary,
backgroundColor: Theme.of(context).colorScheme.secondary,
)
else
Badge(
label: Text('contactMethodVerified'.tr()),
textColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
),
if (contact.isPrimary)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Badge(
label: Text('contactMethodPrimary'.tr()),
textColor: Theme.of(context).colorScheme.onTertiary,
backgroundColor: Theme.of(context).colorScheme.tertiary,
),
),
if (contact.isPublic)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Badge(
label: Text('contactMethodPublic'.tr()),
textColor: Theme.of(context).colorScheme.onPrimary,
backgroundColor: Theme.of(context).colorScheme.primary,
),
),
if (!contact.isPublic)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Badge(
label: Text('contactMethodPrivate'.tr()),
textColor: Theme.of(context).colorScheme.onSurface,
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
),
],
),
],
).padding(all: 20),
const Divider(height: 1),
if (contact.verifiedAt == null)
ListTile(
leading: const Icon(Symbols.verified),
title: Text('contactMethodVerify').tr(),
onTap: verifyContactMethod,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
if (contact.verifiedAt != null && !contact.isPrimary)
ListTile(
leading: const Icon(Symbols.star),
title: Text('contactMethodSetPrimary').tr(),
onTap: setContactMethodAsPrimary,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
if (contact.verifiedAt != null && !contact.isPublic)
ListTile(
leading: const Icon(Symbols.public),
title: Text('contactMethodMakePublic').tr(),
onTap: makeContactMethodPublic,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
if (contact.verifiedAt != null && contact.isPublic)
ListTile(
leading: const Icon(Symbols.visibility_off),
title: Text('contactMethodMakePrivate').tr(),
onTap: makeContactMethodPrivate,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
ListTile(
leading: const Icon(Symbols.delete),
title: Text('contactMethodDelete').tr(),
onTap: deleteContactMethod,
contentPadding: EdgeInsets.symmetric(horizontal: 20),
),
],
),
);
}
}
class ContactMethodNewSheet extends HookConsumerWidget {
const ContactMethodNewSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final contactType = useState<int>(0);
final contentController = useTextEditingController();
Future<void> addContactMethod() async {
if (contentController.text.isEmpty) {
showSnackBar('contactMethodContentEmpty'.tr());
return;
}
try {
showLoadingModal(context);
final apiClient = ref.read(apiClientProvider);
await apiClient.post(
'/pass/accounts/me/contacts',
data: {'type': contactType.value, 'content': contentController.text},
);
if (context.mounted) {
showSnackBar('contactMethodVerificationNeeded'.tr());
Navigator.pop(context, true);
}
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
return SheetScaffold(
titleText: 'contactMethodNew'.tr(),
child: Column(
spacing: 16,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DropdownButtonFormField<int>(
value: contactType.value,
decoration: InputDecoration(
labelText: 'contactMethodType'.tr(),
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem<int>(
value: 0,
child: Row(
children: [
Icon(Symbols.mail),
const Gap(8),
Text('contactMethodTypeEmail'.tr()),
],
),
),
DropdownMenuItem<int>(
value: 1,
child: Row(
children: [
Icon(Symbols.phone),
const Gap(8),
Text('contactMethodTypePhone'.tr()),
],
),
),
DropdownMenuItem<int>(
value: 2,
child: Row(
children: [
Icon(Symbols.home),
const Gap(8),
Text('contactMethodTypeAddress'.tr()),
],
),
),
],
onChanged: (value) {
if (value != null) {
contactType.value = value;
}
},
),
TextField(
controller: contentController,
decoration: InputDecoration(
prefixIcon: Icon(switch (contactType.value) {
0 => Symbols.mail,
1 => Symbols.phone,
_ => Symbols.home,
}),
labelText: switch (contactType.value) {
0 => 'contactMethodTypeEmail'.tr(),
1 => 'contactMethodTypePhone'.tr(),
_ => 'contactMethodTypeAddress'.tr(),
},
hintText: switch (contactType.value) {
0 => 'contactMethodEmailHint'.tr(),
1 => 'contactMethodPhoneHint'.tr(),
_ => 'contactMethodAddressHint'.tr(),
},
border: const OutlineInputBorder(),
),
keyboardType: switch (contactType.value) {
0 => TextInputType.emailAddress,
1 => TextInputType.phone,
_ => TextInputType.multiline,
},
maxLines: switch (contactType.value) {
2 => 3,
_ => 1,
},
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(switch (contactType.value) {
0 => 'contactMethodEmailDescription',
1 => 'contactMethodPhoneDescription',
_ => 'contactMethodAddressDescription',
}).tr(),
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: addContactMethod,
icon: Icon(Symbols.add),
label: Text('create').tr(),
),
],
),
],
).padding(horizontal: 20, vertical: 24),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,537 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'profile.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(account)
final accountProvider = AccountFamily._();
final class AccountProvider
extends
$FunctionalProvider<
AsyncValue<SnAccount>,
SnAccount,
FutureOr<SnAccount>
>
with $FutureModifier<SnAccount>, $FutureProvider<SnAccount> {
AccountProvider._({
required AccountFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'accountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$accountHash();
@override
String toString() {
return r'accountProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnAccount> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<SnAccount> create(Ref ref) {
final argument = this.argument as String;
return account(ref, argument);
}
@override
bool operator ==(Object other) {
return other is AccountProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$accountHash() => r'5e2b7bd59151b4638a5561f495537c259f767123';
final class AccountFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnAccount>, String> {
AccountFamily._()
: super(
retry: null,
name: r'accountProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
AccountProvider call(String uname) =>
AccountProvider._(argument: uname, from: this);
@override
String toString() => r'accountProvider';
}
@ProviderFor(accountBadges)
final accountBadgesProvider = AccountBadgesFamily._();
final class AccountBadgesProvider
extends
$FunctionalProvider<
AsyncValue<List<SnAccountBadge>>,
List<SnAccountBadge>,
FutureOr<List<SnAccountBadge>>
>
with
$FutureModifier<List<SnAccountBadge>>,
$FutureProvider<List<SnAccountBadge>> {
AccountBadgesProvider._({
required AccountBadgesFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'accountBadgesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$accountBadgesHash();
@override
String toString() {
return r'accountBadgesProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<List<SnAccountBadge>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnAccountBadge>> create(Ref ref) {
final argument = this.argument as String;
return accountBadges(ref, argument);
}
@override
bool operator ==(Object other) {
return other is AccountBadgesProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$accountBadgesHash() => r'68db63f49827020beecbdbf20529520d0cd14a7d';
final class AccountBadgesFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<List<SnAccountBadge>>, String> {
AccountBadgesFamily._()
: super(
retry: null,
name: r'accountBadgesProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
AccountBadgesProvider call(String uname) =>
AccountBadgesProvider._(argument: uname, from: this);
@override
String toString() => r'accountBadgesProvider';
}
@ProviderFor(accountAppbarForcegroundColor)
final accountAppbarForcegroundColorProvider =
AccountAppbarForcegroundColorFamily._();
final class AccountAppbarForcegroundColorProvider
extends $FunctionalProvider<AsyncValue<Color?>, Color?, FutureOr<Color?>>
with $FutureModifier<Color?>, $FutureProvider<Color?> {
AccountAppbarForcegroundColorProvider._({
required AccountAppbarForcegroundColorFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'accountAppbarForcegroundColorProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$accountAppbarForcegroundColorHash();
@override
String toString() {
return r'accountAppbarForcegroundColorProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<Color?> $createElement($ProviderPointer pointer) =>
$FutureProviderElement(pointer);
@override
FutureOr<Color?> create(Ref ref) {
final argument = this.argument as String;
return accountAppbarForcegroundColor(ref, argument);
}
@override
bool operator ==(Object other) {
return other is AccountAppbarForcegroundColorProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$accountAppbarForcegroundColorHash() =>
r'59e0049a5158ea653f0afd724df9ff2312b90050';
final class AccountAppbarForcegroundColorFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<Color?>, String> {
AccountAppbarForcegroundColorFamily._()
: super(
retry: null,
name: r'accountAppbarForcegroundColorProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
AccountAppbarForcegroundColorProvider call(String uname) =>
AccountAppbarForcegroundColorProvider._(argument: uname, from: this);
@override
String toString() => r'accountAppbarForcegroundColorProvider';
}
@ProviderFor(accountDirectChat)
final accountDirectChatProvider = AccountDirectChatFamily._();
final class AccountDirectChatProvider
extends
$FunctionalProvider<
AsyncValue<SnChatRoom?>,
SnChatRoom?,
FutureOr<SnChatRoom?>
>
with $FutureModifier<SnChatRoom?>, $FutureProvider<SnChatRoom?> {
AccountDirectChatProvider._({
required AccountDirectChatFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'accountDirectChatProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$accountDirectChatHash();
@override
String toString() {
return r'accountDirectChatProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnChatRoom?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnChatRoom?> create(Ref ref) {
final argument = this.argument as String;
return accountDirectChat(ref, argument);
}
@override
bool operator ==(Object other) {
return other is AccountDirectChatProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$accountDirectChatHash() => r'71bc9eed34a436a3743e8ef87f7aaae861fc5746';
final class AccountDirectChatFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnChatRoom?>, String> {
AccountDirectChatFamily._()
: super(
retry: null,
name: r'accountDirectChatProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
AccountDirectChatProvider call(String uname) =>
AccountDirectChatProvider._(argument: uname, from: this);
@override
String toString() => r'accountDirectChatProvider';
}
@ProviderFor(accountRelationship)
final accountRelationshipProvider = AccountRelationshipFamily._();
final class AccountRelationshipProvider
extends
$FunctionalProvider<
AsyncValue<SnRelationship?>,
SnRelationship?,
FutureOr<SnRelationship?>
>
with $FutureModifier<SnRelationship?>, $FutureProvider<SnRelationship?> {
AccountRelationshipProvider._({
required AccountRelationshipFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'accountRelationshipProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$accountRelationshipHash();
@override
String toString() {
return r'accountRelationshipProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnRelationship?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnRelationship?> create(Ref ref) {
final argument = this.argument as String;
return accountRelationship(ref, argument);
}
@override
bool operator ==(Object other) {
return other is AccountRelationshipProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$accountRelationshipHash() =>
r'319f743261b113a1d3c6a397d48d13c858312669';
final class AccountRelationshipFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnRelationship?>, String> {
AccountRelationshipFamily._()
: super(
retry: null,
name: r'accountRelationshipProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
AccountRelationshipProvider call(String uname) =>
AccountRelationshipProvider._(argument: uname, from: this);
@override
String toString() => r'accountRelationshipProvider';
}
@ProviderFor(accountBotDeveloper)
final accountBotDeveloperProvider = AccountBotDeveloperFamily._();
final class AccountBotDeveloperProvider
extends
$FunctionalProvider<
AsyncValue<SnDeveloper?>,
SnDeveloper?,
FutureOr<SnDeveloper?>
>
with $FutureModifier<SnDeveloper?>, $FutureProvider<SnDeveloper?> {
AccountBotDeveloperProvider._({
required AccountBotDeveloperFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'accountBotDeveloperProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$accountBotDeveloperHash();
@override
String toString() {
return r'accountBotDeveloperProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<SnDeveloper?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnDeveloper?> create(Ref ref) {
final argument = this.argument as String;
return accountBotDeveloper(ref, argument);
}
@override
bool operator ==(Object other) {
return other is AccountBotDeveloperProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$accountBotDeveloperHash() =>
r'673534770640a8cf1484ea0af0f4d0ef283ef157';
final class AccountBotDeveloperFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<SnDeveloper?>, String> {
AccountBotDeveloperFamily._()
: super(
retry: null,
name: r'accountBotDeveloperProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
AccountBotDeveloperProvider call(String uname) =>
AccountBotDeveloperProvider._(argument: uname, from: this);
@override
String toString() => r'accountBotDeveloperProvider';
}
@ProviderFor(accountPublishers)
final accountPublishersProvider = AccountPublishersFamily._();
final class AccountPublishersProvider
extends
$FunctionalProvider<
AsyncValue<List<SnPublisher>>,
List<SnPublisher>,
FutureOr<List<SnPublisher>>
>
with
$FutureModifier<List<SnPublisher>>,
$FutureProvider<List<SnPublisher>> {
AccountPublishersProvider._({
required AccountPublishersFamily super.from,
required String super.argument,
}) : super(
retry: null,
name: r'accountPublishersProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$accountPublishersHash();
@override
String toString() {
return r'accountPublishersProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<List<SnPublisher>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnPublisher>> create(Ref ref) {
final argument = this.argument as String;
return accountPublishers(ref, argument);
}
@override
bool operator ==(Object other) {
return other is AccountPublishersProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$accountPublishersHash() => r'25f5695b4a5154163d77f1769876d826bf736609';
final class AccountPublishersFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<List<SnPublisher>>, String> {
AccountPublishersFamily._()
: super(
retry: null,
name: r'accountPublishersProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
AccountPublishersProvider call(String id) =>
AccountPublishersProvider._(argument: id, from: this);
@override
String toString() => r'accountPublishersProvider';
}

View File

@@ -0,0 +1,469 @@
import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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_widgets/account/account_pfc.dart';
import 'package:island/accounts/accounts_widgets/account/account_picker.dart';
import 'package:island/pagination/pagination.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/shared/widgets/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/accounts/accounts_models/relationship.dart';
import 'package:island/core/network.dart';
part 'relationship.g.dart';
@riverpod
Future<List<SnRelationship>> friendRequest(Ref ref) async {
final client = ref.read(apiClientProvider);
final resp = await client.get('/pass/relationships/requests');
return resp.data
.map((e) => SnRelationship.fromJson(e))
.cast<SnRelationship>()
.toList();
}
final relationshipListNotifierProvider = AsyncNotifierProvider.autoDispose(
RelationshipListNotifier.new,
);
class RelationshipListNotifier
extends AsyncNotifier<PaginationState<SnRelationship>>
with AsyncPaginationController<SnRelationship> {
@override
FutureOr<PaginationState<SnRelationship>> build() async {
final items = await fetch();
return PaginationState(
items: items,
isLoading: false,
isReloading: false,
totalCount: totalCount,
hasMore: hasMore,
cursor: cursor,
);
}
@override
Future<List<SnRelationship>> fetch() async {
final client = ref.read(apiClientProvider);
final take = 20;
final response = await client.get(
'/pass/relationships',
queryParameters: {'offset': fetchedCount.toString(), 'take': take},
);
final List<SnRelationship> items = (response.data as List)
.map((e) => SnRelationship.fromJson(e as Map<String, dynamic>))
.cast<SnRelationship>()
.toList();
totalCount = int.tryParse(response.headers['x-total']?.first ?? '') ?? 0;
return items;
}
}
class RelationshipListTile extends StatelessWidget {
final SnRelationship relationship;
final bool submitting;
final VoidCallback? onAccept;
final VoidCallback? onDecline;
final VoidCallback? onCancel;
final bool showActions;
final String? currentUserId;
final bool showRelatedAccount;
final Function(SnRelationship, int)? onUpdateStatus;
final Function(SnRelationship)? onDelete;
const RelationshipListTile({
super.key,
required this.relationship,
this.submitting = false,
this.onAccept,
this.onDecline,
this.onCancel,
this.showActions = true,
required this.currentUserId,
this.showRelatedAccount = false,
this.onUpdateStatus,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final account = showRelatedAccount
? relationship.related!
: relationship.account!;
final isPending =
relationship.status == 0 && relationship.relatedId == currentUserId;
final isWaiting =
relationship.status == 0 && relationship.accountId == currentUserId;
final isEstablished = relationship.status != 0;
return ListTile(
contentPadding: const EdgeInsets.only(left: 16, right: 12),
leading: AccountPfcRegion(
uname: account.name,
child: ProfilePictureWidget(file: account.profile.picture),
),
title: Row(
spacing: 6,
children: [
Flexible(child: Text(account.nick)),
if (relationship.status >= 100) // Friend
Badge(
label: Text('relationshipStatusFriend').tr(),
backgroundColor: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
)
else if (relationship.status <= -100) // Blocked
Badge(
label: Text('relationshipStatusBlocked').tr(),
backgroundColor: Theme.of(context).colorScheme.error,
textColor: Theme.of(context).colorScheme.onError,
),
if (isPending) // Pending
Badge(
label: Text('pendingRequest').tr(),
backgroundColor: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
)
else if (isWaiting) // Waiting
Badge(
label: Text('pendingRequest').tr(),
backgroundColor: Theme.of(context).colorScheme.secondary,
textColor: Theme.of(context).colorScheme.onSecondary,
),
if (relationship.expiredAt != null)
Badge(
label: Text(
'requestExpiredIn'.tr(
args: [RelativeTime(context).format(relationship.expiredAt!)],
),
),
backgroundColor: Theme.of(context).colorScheme.tertiary,
textColor: Theme.of(context).colorScheme.onTertiary,
),
],
),
subtitle: Text('@${account.name}'),
trailing: showActions
? Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isPending && onAccept != null)
IconButton(
padding: EdgeInsets.zero,
onPressed: submitting ? null : onAccept,
icon: const Icon(Symbols.check),
),
if (isPending && onDecline != null)
IconButton(
padding: EdgeInsets.zero,
onPressed: submitting ? null : onDecline,
icon: const Icon(Symbols.close),
),
if (isWaiting && onCancel != null)
IconButton(
padding: EdgeInsets.zero,
onPressed: submitting ? null : onCancel,
icon: const Icon(Symbols.close),
),
if (isEstablished && onUpdateStatus != null)
PopupMenuButton(
padding: EdgeInsets.zero,
icon: const Icon(Symbols.more_vert),
itemBuilder: (context) => [
if (relationship.status >= 100) // If friend
PopupMenuItem(
onTap: () => onUpdateStatus?.call(relationship, -100),
child: Row(
children: [
const Icon(Symbols.block),
const Gap(12),
Text('blockUser').tr(),
],
),
)
else if (relationship.status <= -100) // If blocked
PopupMenuItem(
onTap: () => onUpdateStatus?.call(relationship, 100),
child: Row(
children: [
const Icon(Symbols.person_add),
const Gap(12),
Text('unblockUser').tr(),
],
),
),
if (onDelete != null)
PopupMenuItem(
onTap: () => onDelete?.call(relationship),
child: Row(
children: [
const Icon(Symbols.delete),
const Gap(12),
Text('forgotRelationship').tr(),
],
),
),
],
),
],
)
: null,
);
}
}
class RelationshipScreen extends HookConsumerWidget {
const RelationshipScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final relationshipNotifier = ref.watch(
relationshipListNotifierProvider.notifier,
);
Future<void> addFriend() async {
final result = await showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => AccountPickerSheet(),
);
if (result == null) return;
final client = ref.read(apiClientProvider);
await client.post('/pass/relationships/${result.id}/friends');
ref.invalidate(friendRequestProvider);
}
final submitting = useState(false);
Future<void> updateRelationship(
SnRelationship relationship,
int newStatus,
) async {
final client = ref.read(apiClientProvider);
await client.patch(
'/pass/relationships/${relationship.accountId}',
data: {'status': newStatus},
);
relationshipNotifier.refresh();
}
Future<void> deleteRelationship(SnRelationship relationship) async {
final confirmed = await showConfirmAlert(
'forgotRelationshipConfirm'.tr(
args: ['@${relationship.related!.name}'],
),
'forgotRelationship'.tr(),
isDanger: true,
);
if (!confirmed) return;
if (!context.mounted) return;
showLoadingModal(context);
try {
final client = ref.read(apiClientProvider);
await client.delete('/pass/relationships/${relationship.relatedId}');
relationshipNotifier.refresh();
showSnackBar('relationshipDeleted'.tr());
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
}
final user = ref.watch(userInfoProvider);
final requests = ref.watch(friendRequestProvider);
return AppScaffold(
appBar: AppBar(title: Text('relationships').tr()),
body: Column(
children: [
ListTile(
leading: const Icon(Symbols.add),
title: Text('addFriend').tr(),
subtitle: Text('addFriendHint').tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: addFriend,
),
if (requests.hasValue && requests.value!.isNotEmpty)
ListTile(
leading: const Icon(Symbols.send),
title: Text('friendRequests').tr(),
subtitle: Text(
'friendRequestsHint'.plural(requests.value!.length),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => const _SentFriendRequestsSheet(),
);
},
),
const Divider(height: 1),
Expanded(
child: PaginationList(
padding: EdgeInsets.zero,
provider: relationshipListNotifierProvider,
notifier: relationshipListNotifierProvider.notifier,
itemBuilder: (context, index, relationship) {
return RelationshipListTile(
relationship: relationship,
submitting: submitting.value,
currentUserId: user.value?.id,
showRelatedAccount: true,
onUpdateStatus: updateRelationship,
onDelete: deleteRelationship,
);
},
),
),
],
),
);
}
}
class _SentFriendRequestsSheet extends HookConsumerWidget {
const _SentFriendRequestsSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final requests = ref.watch(friendRequestProvider);
final user = ref.watch(userInfoProvider);
Future<void> cancelRequest(SnRelationship request) async {
try {
final client = ref.read(apiClientProvider);
await client.delete('/pass/relationships/${request.relatedId}/friends');
ref.invalidate(friendRequestProvider);
} catch (err) {
showErrorAlert(err);
}
}
final submitting = useState(false);
Future<void> handleFriendRequest(
SnRelationship relationship,
bool isAccept,
) async {
try {
submitting.value = true;
final client = ref.read(apiClientProvider);
await client.post(
'/pass/relationships/${relationship.accountId}/friends/${isAccept ? 'accept' : 'decline'}',
);
ref.invalidate(friendRequestProvider);
if (!context.mounted) return;
if (isAccept) {
showSnackBar(
'friendRequestAccepted'.tr(
args: ['@${relationship.account!.name}'],
),
);
} else {
showSnackBar(
'friendRequestDeclined'.tr(
args: ['@${relationship.account!.name}'],
),
);
}
HapticFeedback.lightImpact();
} catch (err) {
showErrorAlert(err);
} finally {
submitting.value = false;
}
}
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(
top: 16,
left: 20,
right: 16,
bottom: 12,
),
child: Row(
children: [
Text(
'friendRequests'.tr(),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.5,
),
),
const Spacer(),
IconButton(
icon: const Icon(Symbols.refresh),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
onPressed: () {
ref.invalidate(friendRequestProvider);
},
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => Navigator.pop(context),
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
),
],
),
),
const Divider(height: 1),
Expanded(
child: requests.when(
data: (items) => items.isEmpty
? Center(
child: Text(
'friendRequestsEmpty'.tr(),
textAlign: TextAlign.center,
),
)
: ListView.builder(
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (context, index) {
final request = items[index];
return RelationshipListTile(
relationship: request,
onCancel: () => cancelRequest(request),
onAccept: () => handleFriendRequest(request, true),
onDecline: () => handleFriendRequest(request, false),
currentUserId: user.value?.id,
showRelatedAccount: true,
);
},
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,51 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'relationship.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(friendRequest)
final friendRequestProvider = FriendRequestProvider._();
final class FriendRequestProvider
extends
$FunctionalProvider<
AsyncValue<List<SnRelationship>>,
List<SnRelationship>,
FutureOr<List<SnRelationship>>
>
with
$FutureModifier<List<SnRelationship>>,
$FutureProvider<List<SnRelationship>> {
FriendRequestProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'friendRequestProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$friendRequestHash();
@$internal
@override
$FutureProviderElement<List<SnRelationship>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnRelationship>> create(Ref ref) {
return friendRequest(ref);
}
}
String _$friendRequestHash() => r'b066553f9bd0505a7a272cf10cb3ca312152acd6';

View File

@@ -0,0 +1,23 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'abuse_report.freezed.dart';
part 'abuse_report.g.dart';
@freezed
sealed class SnAbuseReport with _$SnAbuseReport {
const factory SnAbuseReport({
required String id,
required String resourceIdentifier,
required int type,
required String reason,
required DateTime? resolvedAt,
required String? resolution,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnAbuseReport;
factory SnAbuseReport.fromJson(Map<String, dynamic> json) =>
_$SnAbuseReportFromJson(json);
}

View File

@@ -0,0 +1,298 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'abuse_report.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnAbuseReport {
String get id; String get resourceIdentifier; int get type; String get reason; DateTime? get resolvedAt; String? get resolution; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnAbuseReport
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnAbuseReportCopyWith<SnAbuseReport> get copyWith => _$SnAbuseReportCopyWithImpl<SnAbuseReport>(this as SnAbuseReport, _$identity);
/// Serializes this SnAbuseReport to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnAbuseReport&&(identical(other.id, id) || other.id == id)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&(identical(other.type, type) || other.type == type)&&(identical(other.reason, reason) || other.reason == reason)&&(identical(other.resolvedAt, resolvedAt) || other.resolvedAt == resolvedAt)&&(identical(other.resolution, resolution) || other.resolution == resolution)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,resourceIdentifier,type,reason,resolvedAt,resolution,accountId,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnAbuseReport(id: $id, resourceIdentifier: $resourceIdentifier, type: $type, reason: $reason, resolvedAt: $resolvedAt, resolution: $resolution, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class $SnAbuseReportCopyWith<$Res> {
factory $SnAbuseReportCopyWith(SnAbuseReport value, $Res Function(SnAbuseReport) _then) = _$SnAbuseReportCopyWithImpl;
@useResult
$Res call({
String id, String resourceIdentifier, int type, String reason, DateTime? resolvedAt, String? resolution, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
}
/// @nodoc
class _$SnAbuseReportCopyWithImpl<$Res>
implements $SnAbuseReportCopyWith<$Res> {
_$SnAbuseReportCopyWithImpl(this._self, this._then);
final SnAbuseReport _self;
final $Res Function(SnAbuseReport) _then;
/// Create a copy of SnAbuseReport
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? resourceIdentifier = null,Object? type = null,Object? reason = null,Object? resolvedAt = freezed,Object? resolution = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,resourceIdentifier: null == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as int,reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable
as String,resolvedAt: freezed == resolvedAt ? _self.resolvedAt : resolvedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,resolution: freezed == resolution ? _self.resolution : resolution // ignore: cast_nullable_to_non_nullable
as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
/// Adds pattern-matching-related methods to [SnAbuseReport].
extension SnAbuseReportPatterns on SnAbuseReport {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnAbuseReport value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnAbuseReport() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnAbuseReport value) $default,){
final _that = this;
switch (_that) {
case _SnAbuseReport():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnAbuseReport value)? $default,){
final _that = this;
switch (_that) {
case _SnAbuseReport() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String resourceIdentifier, int type, String reason, DateTime? resolvedAt, String? resolution, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnAbuseReport() when $default != null:
return $default(_that.id,_that.resourceIdentifier,_that.type,_that.reason,_that.resolvedAt,_that.resolution,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String resourceIdentifier, int type, String reason, DateTime? resolvedAt, String? resolution, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _SnAbuseReport():
return $default(_that.id,_that.resourceIdentifier,_that.type,_that.reason,_that.resolvedAt,_that.resolution,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String resourceIdentifier, int type, String reason, DateTime? resolvedAt, String? resolution, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _SnAbuseReport() when $default != null:
return $default(_that.id,_that.resourceIdentifier,_that.type,_that.reason,_that.resolvedAt,_that.resolution,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SnAbuseReport implements SnAbuseReport {
const _SnAbuseReport({required this.id, required this.resourceIdentifier, required this.type, required this.reason, required this.resolvedAt, required this.resolution, required this.accountId, required this.createdAt, required this.updatedAt, required this.deletedAt});
factory _SnAbuseReport.fromJson(Map<String, dynamic> json) => _$SnAbuseReportFromJson(json);
@override final String id;
@override final String resourceIdentifier;
@override final int type;
@override final String reason;
@override final DateTime? resolvedAt;
@override final String? resolution;
@override final String accountId;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final DateTime? deletedAt;
/// Create a copy of SnAbuseReport
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnAbuseReportCopyWith<_SnAbuseReport> get copyWith => __$SnAbuseReportCopyWithImpl<_SnAbuseReport>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnAbuseReportToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnAbuseReport&&(identical(other.id, id) || other.id == id)&&(identical(other.resourceIdentifier, resourceIdentifier) || other.resourceIdentifier == resourceIdentifier)&&(identical(other.type, type) || other.type == type)&&(identical(other.reason, reason) || other.reason == reason)&&(identical(other.resolvedAt, resolvedAt) || other.resolvedAt == resolvedAt)&&(identical(other.resolution, resolution) || other.resolution == resolution)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,resourceIdentifier,type,reason,resolvedAt,resolution,accountId,createdAt,updatedAt,deletedAt);
@override
String toString() {
return 'SnAbuseReport(id: $id, resourceIdentifier: $resourceIdentifier, type: $type, reason: $reason, resolvedAt: $resolvedAt, resolution: $resolution, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
}
}
/// @nodoc
abstract mixin class _$SnAbuseReportCopyWith<$Res> implements $SnAbuseReportCopyWith<$Res> {
factory _$SnAbuseReportCopyWith(_SnAbuseReport value, $Res Function(_SnAbuseReport) _then) = __$SnAbuseReportCopyWithImpl;
@override @useResult
$Res call({
String id, String resourceIdentifier, int type, String reason, DateTime? resolvedAt, String? resolution, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
}
/// @nodoc
class __$SnAbuseReportCopyWithImpl<$Res>
implements _$SnAbuseReportCopyWith<$Res> {
__$SnAbuseReportCopyWithImpl(this._self, this._then);
final _SnAbuseReport _self;
final $Res Function(_SnAbuseReport) _then;
/// Create a copy of SnAbuseReport
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? resourceIdentifier = null,Object? type = null,Object? reason = null,Object? resolvedAt = freezed,Object? resolution = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnAbuseReport(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,resourceIdentifier: null == resourceIdentifier ? _self.resourceIdentifier : resourceIdentifier // ignore: cast_nullable_to_non_nullable
as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as int,reason: null == reason ? _self.reason : reason // ignore: cast_nullable_to_non_nullable
as String,resolvedAt: freezed == resolvedAt ? _self.resolvedAt : resolvedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,resolution: freezed == resolution ? _self.resolution : resolution // ignore: cast_nullable_to_non_nullable
as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,updatedAt: null == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
// dart format on

View File

@@ -0,0 +1,39 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'abuse_report.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnAbuseReport _$SnAbuseReportFromJson(Map<String, dynamic> json) =>
_SnAbuseReport(
id: json['id'] as String,
resourceIdentifier: json['resource_identifier'] as String,
type: (json['type'] as num).toInt(),
reason: json['reason'] as String,
resolvedAt: json['resolved_at'] == null
? null
: DateTime.parse(json['resolved_at'] as String),
resolution: json['resolution'] as String?,
accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnAbuseReportToJson(_SnAbuseReport instance) =>
<String, dynamic>{
'id': instance.id,
'resource_identifier': instance.resourceIdentifier,
'type': instance.type,
'reason': instance.reason,
'resolved_at': instance.resolvedAt?.toIso8601String(),
'resolution': instance.resolution,
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};

View File

@@ -0,0 +1,38 @@
enum AbuseReportType {
copyright(0),
harassment(1),
impersonation(2),
offensiveContent(3),
spam(4),
privacyViolation(5),
illegalContent(6),
other(7);
const AbuseReportType(this.value);
final int value;
static AbuseReportType fromValue(int value) {
return values.firstWhere((e) => e.value == value);
}
String get displayName {
switch (this) {
case AbuseReportType.copyright:
return 'Copyright';
case AbuseReportType.harassment:
return 'Harassment';
case AbuseReportType.impersonation:
return 'Impersonation';
case AbuseReportType.offensiveContent:
return 'Offensive Content';
case AbuseReportType.spam:
return 'Spam';
case AbuseReportType.privacyViolation:
return 'Privacy Violation';
case AbuseReportType.illegalContent:
return 'Illegal Content';
case AbuseReportType.other:
return 'Other';
}
}
}

View File

@@ -0,0 +1,279 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/core/models/activity.dart';
import 'package:island/auth/auth_models/auth.dart';
import 'package:island/drive/drive_models/file.dart';
import 'package:island/wallet/wallet_models/wallet.dart';
part 'account.freezed.dart';
part 'account.g.dart';
@freezed
sealed class SnAccount with _$SnAccount {
const factory SnAccount({
required String id,
required String name,
required String nick,
required String language,
@Default("") String region,
required bool isSuperuser,
required String? automatedId,
required SnAccountProfile profile,
required SnWalletSubscriptionRef? perkSubscription,
@Default([]) List<SnAccountBadge> badges,
@Default([]) List<SnContactMethod> contacts,
required DateTime? activatedAt,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnAccount;
factory SnAccount.fromJson(Map<String, dynamic> json) =>
_$SnAccountFromJson(json);
}
@freezed
sealed class ProfileLink with _$ProfileLink {
const factory ProfileLink({required String name, required String url}) =
_ProfileLink;
factory ProfileLink.fromJson(Map<String, dynamic> json) =>
_$ProfileLinkFromJson(json);
}
class ProfileLinkConverter
implements JsonConverter<List<ProfileLink>, dynamic> {
const ProfileLinkConverter();
@override
List<ProfileLink> fromJson(dynamic json) {
return json is List<dynamic>
? json.map((e) => ProfileLink.fromJson(e)).cast<ProfileLink>().toList()
: <ProfileLink>[];
}
@override
List<dynamic> toJson(List<ProfileLink> object) {
return object.map((e) => e.toJson()).toList();
}
}
@freezed
sealed class UsernameColor with _$UsernameColor {
const factory UsernameColor({
@Default('plain') String type,
String? value,
String? direction,
List<String>? colors,
}) = _UsernameColor;
factory UsernameColor.fromJson(Map<String, dynamic> json) =>
_$UsernameColorFromJson(json);
}
@freezed
sealed class SnAccountProfile with _$SnAccountProfile {
const factory SnAccountProfile({
required String id,
@Default('') String firstName,
@Default('') String middleName,
@Default('') String lastName,
@Default('') String bio,
@Default('') String gender,
@Default('') String pronouns,
@Default('') String location,
@Default('') String timeZone,
DateTime? birthday,
@ProfileLinkConverter() @Default([]) List<ProfileLink> links,
DateTime? lastSeenAt,
SnAccountBadge? activeBadge,
required int experience,
required int level,
@Default(100) double socialCredits,
@Default(0) int socialCreditsLevel,
required double levelingProgress,
required SnCloudFile? picture,
required SnCloudFile? background,
required SnVerificationMark? verification,
UsernameColor? usernameColor,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnAccountProfile;
factory SnAccountProfile.fromJson(Map<String, dynamic> json) =>
_$SnAccountProfileFromJson(json);
}
@freezed
sealed class SnAccountStatus with _$SnAccountStatus {
const factory SnAccountStatus({
required String id,
required int attitude,
required bool isOnline,
required bool isInvisible,
required bool isNotDisturb,
required bool isCustomized,
@Default("") String label,
required Map<String, dynamic>? meta,
required DateTime? clearedAt,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnAccountStatus;
factory SnAccountStatus.fromJson(Map<String, dynamic> json) =>
_$SnAccountStatusFromJson(json);
}
@freezed
sealed class SnAccountBadge with _$SnAccountBadge {
const factory SnAccountBadge({
required String id,
required String type,
required String? label,
required String? caption,
required Map<String, dynamic> meta,
required DateTime? expiredAt,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? activatedAt,
required DateTime? deletedAt,
}) = _SnAccountBadge;
factory SnAccountBadge.fromJson(Map<String, dynamic> json) =>
_$SnAccountBadgeFromJson(json);
}
@freezed
sealed class SnContactMethod with _$SnContactMethod {
const factory SnContactMethod({
required String id,
required int type,
required DateTime? verifiedAt,
required bool isPrimary,
required bool isPublic,
required String content,
required String accountId,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnContactMethod;
factory SnContactMethod.fromJson(Map<String, dynamic> json) =>
_$SnContactMethodFromJson(json);
}
@freezed
sealed class SnNotification with _$SnNotification {
const factory SnNotification({
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String id,
required String topic,
required String title,
@Default('') String subtitle,
required String content,
@Default({}) Map<String, dynamic> meta,
required int priority,
required DateTime? viewedAt,
required String accountId,
}) = _SnNotification;
factory SnNotification.fromJson(Map<String, dynamic> json) =>
_$SnNotificationFromJson(json);
}
@freezed
sealed class SnVerificationMark with _$SnVerificationMark {
const factory SnVerificationMark({
required int type,
required String? title,
required String? description,
required String? verifiedBy,
}) = _SnVerificationMark;
factory SnVerificationMark.fromJson(Map<String, dynamic> json) =>
_$SnVerificationMarkFromJson(json);
}
@freezed
sealed class SnAuthDevice with _$SnAuthDevice {
const factory SnAuthDevice({
required String id,
required String deviceId,
required String deviceName,
required String? deviceLabel,
required String accountId,
required int platform,
@Default(false) bool isCurrent,
}) = _SnAuthDevice;
factory SnAuthDevice.fromJson(Map<String, dynamic> json) =>
_$SnAuthDeviceFromJson(json);
}
@freezed
sealed class SnAuthDeviceWithSession with _$SnAuthDeviceWithSession {
const factory SnAuthDeviceWithSession({
required String id,
required String deviceId,
required String deviceName,
required String? deviceLabel,
required String accountId,
required int platform,
required List<SnAuthSession> sessions,
@Default(false) bool isCurrent,
}) = _SnAuthDeviceWithSessione;
factory SnAuthDeviceWithSession.fromJson(Map<String, dynamic> json) =>
_$SnAuthDeviceWithSessionFromJson(json);
}
@freezed
sealed class SnExperienceRecord with _$SnExperienceRecord {
const factory SnExperienceRecord({
required String id,
required int delta,
required String reasonType,
required String reason,
@Default(1.0) double? bonusMultiplier,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnExperienceRecord;
factory SnExperienceRecord.fromJson(Map<String, dynamic> json) =>
_$SnExperienceRecordFromJson(json);
}
@freezed
sealed class SnSocialCreditRecord with _$SnSocialCreditRecord {
const factory SnSocialCreditRecord({
required String id,
required double delta,
required String reasonType,
required String reason,
required DateTime? expiredAt,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
}) = _SnSocialCreditRecord;
factory SnSocialCreditRecord.fromJson(Map<String, dynamic> json) =>
_$SnSocialCreditRecordFromJson(json);
}
@freezed
sealed class SnFriendOverviewItem with _$SnFriendOverviewItem {
const factory SnFriendOverviewItem({
required SnAccount account,
required SnAccountStatus status,
required List<SnPresenceActivity> activities,
}) = _SnFriendOverviewItem;
factory SnFriendOverviewItem.fromJson(Map<String, dynamic> json) =>
_$SnFriendOverviewItemFromJson(json);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,445 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'account.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnAccount _$SnAccountFromJson(Map<String, dynamic> json) => _SnAccount(
id: json['id'] as String,
name: json['name'] as String,
nick: json['nick'] as String,
language: json['language'] as String,
region: json['region'] as String? ?? "",
isSuperuser: json['is_superuser'] as bool,
automatedId: json['automated_id'] as String?,
profile: SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>),
perkSubscription: json['perk_subscription'] == null
? null
: SnWalletSubscriptionRef.fromJson(
json['perk_subscription'] as Map<String, dynamic>,
),
badges:
(json['badges'] as List<dynamic>?)
?.map((e) => SnAccountBadge.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
contacts:
(json['contacts'] as List<dynamic>?)
?.map((e) => SnContactMethod.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
activatedAt: json['activated_at'] == null
? null
: DateTime.parse(json['activated_at'] as String),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnAccountToJson(_SnAccount instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'nick': instance.nick,
'language': instance.language,
'region': instance.region,
'is_superuser': instance.isSuperuser,
'automated_id': instance.automatedId,
'profile': instance.profile.toJson(),
'perk_subscription': instance.perkSubscription?.toJson(),
'badges': instance.badges.map((e) => e.toJson()).toList(),
'contacts': instance.contacts.map((e) => e.toJson()).toList(),
'activated_at': instance.activatedAt?.toIso8601String(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_ProfileLink _$ProfileLinkFromJson(Map<String, dynamic> json) =>
_ProfileLink(name: json['name'] as String, url: json['url'] as String);
Map<String, dynamic> _$ProfileLinkToJson(_ProfileLink instance) =>
<String, dynamic>{'name': instance.name, 'url': instance.url};
_UsernameColor _$UsernameColorFromJson(Map<String, dynamic> json) =>
_UsernameColor(
type: json['type'] as String? ?? 'plain',
value: json['value'] as String?,
direction: json['direction'] as String?,
colors: (json['colors'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
);
Map<String, dynamic> _$UsernameColorToJson(_UsernameColor instance) =>
<String, dynamic>{
'type': instance.type,
'value': instance.value,
'direction': instance.direction,
'colors': instance.colors,
};
_SnAccountProfile _$SnAccountProfileFromJson(
Map<String, dynamic> json,
) => _SnAccountProfile(
id: json['id'] as String,
firstName: json['first_name'] as String? ?? '',
middleName: json['middle_name'] as String? ?? '',
lastName: json['last_name'] as String? ?? '',
bio: json['bio'] as String? ?? '',
gender: json['gender'] as String? ?? '',
pronouns: json['pronouns'] as String? ?? '',
location: json['location'] as String? ?? '',
timeZone: json['time_zone'] as String? ?? '',
birthday: json['birthday'] == null
? null
: DateTime.parse(json['birthday'] as String),
links: json['links'] == null
? const []
: const ProfileLinkConverter().fromJson(json['links']),
lastSeenAt: json['last_seen_at'] == null
? null
: DateTime.parse(json['last_seen_at'] as String),
activeBadge: json['active_badge'] == null
? null
: SnAccountBadge.fromJson(json['active_badge'] as Map<String, dynamic>),
experience: (json['experience'] as num).toInt(),
level: (json['level'] as num).toInt(),
socialCredits: (json['social_credits'] as num?)?.toDouble() ?? 100,
socialCreditsLevel: (json['social_credits_level'] as num?)?.toInt() ?? 0,
levelingProgress: (json['leveling_progress'] as num).toDouble(),
picture: json['picture'] == null
? null
: SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>),
background: json['background'] == null
? null
: SnCloudFile.fromJson(json['background'] as Map<String, dynamic>),
verification: json['verification'] == null
? null
: SnVerificationMark.fromJson(
json['verification'] as Map<String, dynamic>,
),
usernameColor: json['username_color'] == null
? null
: UsernameColor.fromJson(json['username_color'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnAccountProfileToJson(_SnAccountProfile instance) =>
<String, dynamic>{
'id': instance.id,
'first_name': instance.firstName,
'middle_name': instance.middleName,
'last_name': instance.lastName,
'bio': instance.bio,
'gender': instance.gender,
'pronouns': instance.pronouns,
'location': instance.location,
'time_zone': instance.timeZone,
'birthday': instance.birthday?.toIso8601String(),
'links': const ProfileLinkConverter().toJson(instance.links),
'last_seen_at': instance.lastSeenAt?.toIso8601String(),
'active_badge': instance.activeBadge?.toJson(),
'experience': instance.experience,
'level': instance.level,
'social_credits': instance.socialCredits,
'social_credits_level': instance.socialCreditsLevel,
'leveling_progress': instance.levelingProgress,
'picture': instance.picture?.toJson(),
'background': instance.background?.toJson(),
'verification': instance.verification?.toJson(),
'username_color': instance.usernameColor?.toJson(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnAccountStatus _$SnAccountStatusFromJson(Map<String, dynamic> json) =>
_SnAccountStatus(
id: json['id'] as String,
attitude: (json['attitude'] as num).toInt(),
isOnline: json['is_online'] as bool,
isInvisible: json['is_invisible'] as bool,
isNotDisturb: json['is_not_disturb'] as bool,
isCustomized: json['is_customized'] as bool,
label: json['label'] as String? ?? "",
meta: json['meta'] as Map<String, dynamic>?,
clearedAt: json['cleared_at'] == null
? null
: DateTime.parse(json['cleared_at'] as String),
accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnAccountStatusToJson(_SnAccountStatus instance) =>
<String, dynamic>{
'id': instance.id,
'attitude': instance.attitude,
'is_online': instance.isOnline,
'is_invisible': instance.isInvisible,
'is_not_disturb': instance.isNotDisturb,
'is_customized': instance.isCustomized,
'label': instance.label,
'meta': instance.meta,
'cleared_at': instance.clearedAt?.toIso8601String(),
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnAccountBadge _$SnAccountBadgeFromJson(Map<String, dynamic> json) =>
_SnAccountBadge(
id: json['id'] as String,
type: json['type'] as String,
label: json['label'] as String?,
caption: json['caption'] as String?,
meta: json['meta'] as Map<String, dynamic>,
expiredAt: json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
activatedAt: json['activated_at'] == null
? null
: DateTime.parse(json['activated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnAccountBadgeToJson(_SnAccountBadge instance) =>
<String, dynamic>{
'id': instance.id,
'type': instance.type,
'label': instance.label,
'caption': instance.caption,
'meta': instance.meta,
'expired_at': instance.expiredAt?.toIso8601String(),
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'activated_at': instance.activatedAt?.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnContactMethod _$SnContactMethodFromJson(Map<String, dynamic> json) =>
_SnContactMethod(
id: json['id'] as String,
type: (json['type'] as num).toInt(),
verifiedAt: json['verified_at'] == null
? null
: DateTime.parse(json['verified_at'] as String),
isPrimary: json['is_primary'] as bool,
isPublic: json['is_public'] as bool,
content: json['content'] as String,
accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnContactMethodToJson(_SnContactMethod instance) =>
<String, dynamic>{
'id': instance.id,
'type': instance.type,
'verified_at': instance.verifiedAt?.toIso8601String(),
'is_primary': instance.isPrimary,
'is_public': instance.isPublic,
'content': instance.content,
'account_id': instance.accountId,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnNotification _$SnNotificationFromJson(Map<String, dynamic> json) =>
_SnNotification(
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
id: json['id'] as String,
topic: json['topic'] as String,
title: json['title'] as String,
subtitle: json['subtitle'] as String? ?? '',
content: json['content'] as String,
meta: json['meta'] as Map<String, dynamic>? ?? const {},
priority: (json['priority'] as num).toInt(),
viewedAt: json['viewed_at'] == null
? null
: DateTime.parse(json['viewed_at'] as String),
accountId: json['account_id'] as String,
);
Map<String, dynamic> _$SnNotificationToJson(_SnNotification instance) =>
<String, dynamic>{
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'id': instance.id,
'topic': instance.topic,
'title': instance.title,
'subtitle': instance.subtitle,
'content': instance.content,
'meta': instance.meta,
'priority': instance.priority,
'viewed_at': instance.viewedAt?.toIso8601String(),
'account_id': instance.accountId,
};
_SnVerificationMark _$SnVerificationMarkFromJson(Map<String, dynamic> json) =>
_SnVerificationMark(
type: (json['type'] as num).toInt(),
title: json['title'] as String?,
description: json['description'] as String?,
verifiedBy: json['verified_by'] as String?,
);
Map<String, dynamic> _$SnVerificationMarkToJson(_SnVerificationMark instance) =>
<String, dynamic>{
'type': instance.type,
'title': instance.title,
'description': instance.description,
'verified_by': instance.verifiedBy,
};
_SnAuthDevice _$SnAuthDeviceFromJson(Map<String, dynamic> json) =>
_SnAuthDevice(
id: json['id'] as String,
deviceId: json['device_id'] as String,
deviceName: json['device_name'] as String,
deviceLabel: json['device_label'] as String?,
accountId: json['account_id'] as String,
platform: (json['platform'] as num).toInt(),
isCurrent: json['is_current'] as bool? ?? false,
);
Map<String, dynamic> _$SnAuthDeviceToJson(_SnAuthDevice instance) =>
<String, dynamic>{
'id': instance.id,
'device_id': instance.deviceId,
'device_name': instance.deviceName,
'device_label': instance.deviceLabel,
'account_id': instance.accountId,
'platform': instance.platform,
'is_current': instance.isCurrent,
};
_SnAuthDeviceWithSessione _$SnAuthDeviceWithSessioneFromJson(
Map<String, dynamic> json,
) => _SnAuthDeviceWithSessione(
id: json['id'] as String,
deviceId: json['device_id'] as String,
deviceName: json['device_name'] as String,
deviceLabel: json['device_label'] as String?,
accountId: json['account_id'] as String,
platform: (json['platform'] as num).toInt(),
sessions: (json['sessions'] as List<dynamic>)
.map((e) => SnAuthSession.fromJson(e as Map<String, dynamic>))
.toList(),
isCurrent: json['is_current'] as bool? ?? false,
);
Map<String, dynamic> _$SnAuthDeviceWithSessioneToJson(
_SnAuthDeviceWithSessione instance,
) => <String, dynamic>{
'id': instance.id,
'device_id': instance.deviceId,
'device_name': instance.deviceName,
'device_label': instance.deviceLabel,
'account_id': instance.accountId,
'platform': instance.platform,
'sessions': instance.sessions.map((e) => e.toJson()).toList(),
'is_current': instance.isCurrent,
};
_SnExperienceRecord _$SnExperienceRecordFromJson(Map<String, dynamic> json) =>
_SnExperienceRecord(
id: json['id'] as String,
delta: (json['delta'] as num).toInt(),
reasonType: json['reason_type'] as String,
reason: json['reason'] as String,
bonusMultiplier: (json['bonus_multiplier'] as num?)?.toDouble() ?? 1.0,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnExperienceRecordToJson(_SnExperienceRecord instance) =>
<String, dynamic>{
'id': instance.id,
'delta': instance.delta,
'reason_type': instance.reasonType,
'reason': instance.reason,
'bonus_multiplier': instance.bonusMultiplier,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnSocialCreditRecord _$SnSocialCreditRecordFromJson(
Map<String, dynamic> json,
) => _SnSocialCreditRecord(
id: json['id'] as String,
delta: (json['delta'] as num).toDouble(),
reasonType: json['reason_type'] as String,
reason: json['reason'] as String,
expiredAt: json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnSocialCreditRecordToJson(
_SnSocialCreditRecord instance,
) => <String, dynamic>{
'id': instance.id,
'delta': instance.delta,
'reason_type': instance.reasonType,
'reason': instance.reason,
'expired_at': instance.expiredAt?.toIso8601String(),
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
};
_SnFriendOverviewItem _$SnFriendOverviewItemFromJson(
Map<String, dynamic> json,
) => _SnFriendOverviewItem(
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
status: SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
activities: (json['activities'] as List<dynamic>)
.map((e) => SnPresenceActivity.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$SnFriendOverviewItemToJson(
_SnFriendOverviewItem instance,
) => <String, dynamic>{
'account': instance.account.toJson(),
'status': instance.status.toJson(),
'activities': instance.activities.map((e) => e.toJson()).toList(),
};

View File

@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
class BadgeInfo {
final String type;
final String name;
final String description;
final IconData icon;
final Color color;
const BadgeInfo({
required this.type,
required this.name,
required this.description,
this.icon = Icons.star,
this.color = Colors.blue,
});
}
const Map<String, BadgeInfo> kBadgeTemplates = {
'achievements.post.first': BadgeInfo(
type: 'achievements.post.first',
name: 'firstPostBadgeName',
description: 'firstPostBadgeDescription',
icon: Icons.create,
color: Colors.green,
),
'achievements.post.popular': BadgeInfo(
type: 'achievements.post.popular',
name: 'popularPostBadgeName',
description: 'popularPostBadgeDescription',
icon: Icons.trending_up,
color: Colors.orange,
),
'achievements.post.viral': BadgeInfo(
type: 'achievements.post.viral',
name: 'viralPostBadgeName',
description: 'viralPostBadgeDescription',
icon: Icons.whatshot,
color: Colors.red,
),
'achievements.comment.helpful': BadgeInfo(
type: 'achievements.comment.helpful',
name: 'helpfulCommentBadgeName',
description: 'helpfulCommentBadgeDescription',
icon: Icons.thumb_up,
color: Colors.lightBlue,
),
'ranks.newcomer': BadgeInfo(
type: 'ranks.newcomer',
name: 'newcomerBadgeName',
description: 'newcomerBadgeDescription',
icon: Icons.person_outline,
color: Colors.blue,
),
'ranks.contributor': BadgeInfo(
type: 'ranks.contributor',
name: 'contributorBadgeName',
description: 'contributorBadgeDescription',
icon: Icons.stars,
color: Colors.purple,
),
'ranks.expert': BadgeInfo(
type: 'ranks.expert',
name: 'expertBadgeName',
description: 'expertBadgeDescription',
icon: Icons.workspace_premium,
color: Colors.amber,
),
'event.founder': BadgeInfo(
type: 'event.founder',
name: 'founderBadgeName',
description: 'founderBadgeDescription',
icon: Icons.foundation,
color: Colors.deepPurple,
),
'event.beta.tester': BadgeInfo(
type: 'event.beta.tester',
name: 'betaTesterBadgeName',
description: 'betaTesterBadgeDescription',
icon: Icons.bug_report,
color: Colors.teal,
),
'special.moderator': BadgeInfo(
type: 'special.moderator',
name: 'moderatorBadgeName',
description: 'moderatorBadgeDescription',
icon: Icons.construction,
color: Colors.indigo,
),
'special.developer': BadgeInfo(
type: 'special.developer',
name: 'developerBadgeName',
description: 'developerBadgeDescription',
icon: Icons.code,
color: Colors.indigo,
),
'special.translator': BadgeInfo(
type: 'special.translator',
name: 'translatorBadgeName',
description: 'translatorBadgeDescription',
icon: Icons.code,
color: Colors.grey,
),
};

View File

@@ -0,0 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'fortune.g.dart';
part 'fortune.freezed.dart';
@freezed
sealed class SnFortuneSaying with _$SnFortuneSaying {
const factory SnFortuneSaying({
required String content,
required String source,
required String language,
}) = _SnFortuneSaying;
factory SnFortuneSaying.fromJson(Map<String, dynamic> json) =>
_$SnFortuneSayingFromJson(json);
}

View File

@@ -0,0 +1,277 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'fortune.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnFortuneSaying {
String get content; String get source; String get language;
/// Create a copy of SnFortuneSaying
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnFortuneSayingCopyWith<SnFortuneSaying> get copyWith => _$SnFortuneSayingCopyWithImpl<SnFortuneSaying>(this as SnFortuneSaying, _$identity);
/// Serializes this SnFortuneSaying to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnFortuneSaying&&(identical(other.content, content) || other.content == content)&&(identical(other.source, source) || other.source == source)&&(identical(other.language, language) || other.language == language));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,content,source,language);
@override
String toString() {
return 'SnFortuneSaying(content: $content, source: $source, language: $language)';
}
}
/// @nodoc
abstract mixin class $SnFortuneSayingCopyWith<$Res> {
factory $SnFortuneSayingCopyWith(SnFortuneSaying value, $Res Function(SnFortuneSaying) _then) = _$SnFortuneSayingCopyWithImpl;
@useResult
$Res call({
String content, String source, String language
});
}
/// @nodoc
class _$SnFortuneSayingCopyWithImpl<$Res>
implements $SnFortuneSayingCopyWith<$Res> {
_$SnFortuneSayingCopyWithImpl(this._self, this._then);
final SnFortuneSaying _self;
final $Res Function(SnFortuneSaying) _then;
/// Create a copy of SnFortuneSaying
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? content = null,Object? source = null,Object? language = null,}) {
return _then(_self.copyWith(
content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable
as String,language: null == language ? _self.language : language // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [SnFortuneSaying].
extension SnFortuneSayingPatterns on SnFortuneSaying {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnFortuneSaying value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnFortuneSaying() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnFortuneSaying value) $default,){
final _that = this;
switch (_that) {
case _SnFortuneSaying():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnFortuneSaying value)? $default,){
final _that = this;
switch (_that) {
case _SnFortuneSaying() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String content, String source, String language)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnFortuneSaying() when $default != null:
return $default(_that.content,_that.source,_that.language);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String content, String source, String language) $default,) {final _that = this;
switch (_that) {
case _SnFortuneSaying():
return $default(_that.content,_that.source,_that.language);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String content, String source, String language)? $default,) {final _that = this;
switch (_that) {
case _SnFortuneSaying() when $default != null:
return $default(_that.content,_that.source,_that.language);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SnFortuneSaying implements SnFortuneSaying {
const _SnFortuneSaying({required this.content, required this.source, required this.language});
factory _SnFortuneSaying.fromJson(Map<String, dynamic> json) => _$SnFortuneSayingFromJson(json);
@override final String content;
@override final String source;
@override final String language;
/// Create a copy of SnFortuneSaying
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnFortuneSayingCopyWith<_SnFortuneSaying> get copyWith => __$SnFortuneSayingCopyWithImpl<_SnFortuneSaying>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnFortuneSayingToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnFortuneSaying&&(identical(other.content, content) || other.content == content)&&(identical(other.source, source) || other.source == source)&&(identical(other.language, language) || other.language == language));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,content,source,language);
@override
String toString() {
return 'SnFortuneSaying(content: $content, source: $source, language: $language)';
}
}
/// @nodoc
abstract mixin class _$SnFortuneSayingCopyWith<$Res> implements $SnFortuneSayingCopyWith<$Res> {
factory _$SnFortuneSayingCopyWith(_SnFortuneSaying value, $Res Function(_SnFortuneSaying) _then) = __$SnFortuneSayingCopyWithImpl;
@override @useResult
$Res call({
String content, String source, String language
});
}
/// @nodoc
class __$SnFortuneSayingCopyWithImpl<$Res>
implements _$SnFortuneSayingCopyWith<$Res> {
__$SnFortuneSayingCopyWithImpl(this._self, this._then);
final _SnFortuneSaying _self;
final $Res Function(_SnFortuneSaying) _then;
/// Create a copy of SnFortuneSaying
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? content = null,Object? source = null,Object? language = null,}) {
return _then(_SnFortuneSaying(
content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable
as String,language: null == language ? _self.language : language // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
// dart format on

View File

@@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'fortune.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnFortuneSaying _$SnFortuneSayingFromJson(Map<String, dynamic> json) =>
_SnFortuneSaying(
content: json['content'] as String,
source: json['source'] as String,
language: json['language'] as String,
);
Map<String, dynamic> _$SnFortuneSayingToJson(_SnFortuneSaying instance) =>
<String, dynamic>{
'content': instance.content,
'source': instance.source,
'language': instance.language,
};

View File

@@ -0,0 +1,25 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/accounts/accounts_models/account.dart';
part 'relationship.freezed.dart';
part 'relationship.g.dart';
@freezed
sealed class SnRelationship with _$SnRelationship {
const factory SnRelationship({
required DateTime? createdAt,
required DateTime? updatedAt,
required DateTime? deletedAt,
required String accountId,
// Usually the account was not included in the response
required SnAccount? account,
required String relatedId,
required SnAccount? related,
required DateTime? expiredAt,
required int status,
}) = _SnRelationship;
factory SnRelationship.fromJson(Map<String, dynamic> json) =>
_$SnRelationshipFromJson(json);
}

View File

@@ -0,0 +1,345 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'relationship.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SnRelationship {
DateTime? get createdAt; DateTime? get updatedAt; DateTime? get deletedAt; String get accountId;// Usually the account was not included in the response
SnAccount? get account; String get relatedId; SnAccount? get related; DateTime? get expiredAt; int get status;
/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SnRelationshipCopyWith<SnRelationship> get copyWith => _$SnRelationshipCopyWithImpl<SnRelationship>(this as SnRelationship, _$identity);
/// Serializes this SnRelationship to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnRelationship&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.relatedId, relatedId) || other.relatedId == relatedId)&&(identical(other.related, related) || other.related == related)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.status, status) || other.status == status));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,accountId,account,relatedId,related,expiredAt,status);
@override
String toString() {
return 'SnRelationship(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, accountId: $accountId, account: $account, relatedId: $relatedId, related: $related, expiredAt: $expiredAt, status: $status)';
}
}
/// @nodoc
abstract mixin class $SnRelationshipCopyWith<$Res> {
factory $SnRelationshipCopyWith(SnRelationship value, $Res Function(SnRelationship) _then) = _$SnRelationshipCopyWithImpl;
@useResult
$Res call({
DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status
});
$SnAccountCopyWith<$Res>? get account;$SnAccountCopyWith<$Res>? get related;
}
/// @nodoc
class _$SnRelationshipCopyWithImpl<$Res>
implements $SnRelationshipCopyWith<$Res> {
_$SnRelationshipCopyWithImpl(this._self, this._then);
final SnRelationship _self;
final $Res Function(SnRelationship) _then;
/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? accountId = null,Object? account = freezed,Object? relatedId = null,Object? related = freezed,Object? expiredAt = freezed,Object? status = null,}) {
return _then(_self.copyWith(
createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount?,relatedId: null == relatedId ? _self.relatedId : relatedId // ignore: cast_nullable_to_non_nullable
as String,related: freezed == related ? _self.related : related // ignore: cast_nullable_to_non_nullable
as SnAccount?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
}/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get related {
if (_self.related == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.related!, (value) {
return _then(_self.copyWith(related: value));
});
}
}
/// Adds pattern-matching-related methods to [SnRelationship].
extension SnRelationshipPatterns on SnRelationship {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnRelationship value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SnRelationship() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnRelationship value) $default,){
final _that = this;
switch (_that) {
case _SnRelationship():
return $default(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnRelationship value)? $default,){
final _that = this;
switch (_that) {
case _SnRelationship() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnRelationship() when $default != null:
return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,_that.account,_that.relatedId,_that.related,_that.expiredAt,_that.status);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status) $default,) {final _that = this;
switch (_that) {
case _SnRelationship():
return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,_that.account,_that.relatedId,_that.related,_that.expiredAt,_that.status);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status)? $default,) {final _that = this;
switch (_that) {
case _SnRelationship() when $default != null:
return $default(_that.createdAt,_that.updatedAt,_that.deletedAt,_that.accountId,_that.account,_that.relatedId,_that.related,_that.expiredAt,_that.status);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _SnRelationship implements SnRelationship {
const _SnRelationship({required this.createdAt, required this.updatedAt, required this.deletedAt, required this.accountId, required this.account, required this.relatedId, required this.related, required this.expiredAt, required this.status});
factory _SnRelationship.fromJson(Map<String, dynamic> json) => _$SnRelationshipFromJson(json);
@override final DateTime? createdAt;
@override final DateTime? updatedAt;
@override final DateTime? deletedAt;
@override final String accountId;
// Usually the account was not included in the response
@override final SnAccount? account;
@override final String relatedId;
@override final SnAccount? related;
@override final DateTime? expiredAt;
@override final int status;
/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SnRelationshipCopyWith<_SnRelationship> get copyWith => __$SnRelationshipCopyWithImpl<_SnRelationship>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SnRelationshipToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnRelationship&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.account, account) || other.account == account)&&(identical(other.relatedId, relatedId) || other.relatedId == relatedId)&&(identical(other.related, related) || other.related == related)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&(identical(other.status, status) || other.status == status));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,createdAt,updatedAt,deletedAt,accountId,account,relatedId,related,expiredAt,status);
@override
String toString() {
return 'SnRelationship(createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, accountId: $accountId, account: $account, relatedId: $relatedId, related: $related, expiredAt: $expiredAt, status: $status)';
}
}
/// @nodoc
abstract mixin class _$SnRelationshipCopyWith<$Res> implements $SnRelationshipCopyWith<$Res> {
factory _$SnRelationshipCopyWith(_SnRelationship value, $Res Function(_SnRelationship) _then) = __$SnRelationshipCopyWithImpl;
@override @useResult
$Res call({
DateTime? createdAt, DateTime? updatedAt, DateTime? deletedAt, String accountId, SnAccount? account, String relatedId, SnAccount? related, DateTime? expiredAt, int status
});
@override $SnAccountCopyWith<$Res>? get account;@override $SnAccountCopyWith<$Res>? get related;
}
/// @nodoc
class __$SnRelationshipCopyWithImpl<$Res>
implements _$SnRelationshipCopyWith<$Res> {
__$SnRelationshipCopyWithImpl(this._self, this._then);
final _SnRelationship _self;
final $Res Function(_SnRelationship) _then;
/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? createdAt = freezed,Object? updatedAt = freezed,Object? deletedAt = freezed,Object? accountId = null,Object? account = freezed,Object? relatedId = null,Object? related = freezed,Object? expiredAt = freezed,Object? status = null,}) {
return _then(_SnRelationship(
createdAt: freezed == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,account: freezed == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
as SnAccount?,relatedId: null == relatedId ? _self.relatedId : relatedId // ignore: cast_nullable_to_non_nullable
as String,related: freezed == related ? _self.related : related // ignore: cast_nullable_to_non_nullable
as SnAccount?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
as int,
));
}
/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get account {
if (_self.account == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.account!, (value) {
return _then(_self.copyWith(account: value));
});
}/// Create a copy of SnRelationship
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnAccountCopyWith<$Res>? get related {
if (_self.related == null) {
return null;
}
return $SnAccountCopyWith<$Res>(_self.related!, (value) {
return _then(_self.copyWith(related: value));
});
}
}
// dart format on

View File

@@ -0,0 +1,45 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'relationship.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_SnRelationship _$SnRelationshipFromJson(Map<String, dynamic> json) =>
_SnRelationship(
createdAt: json['created_at'] == null
? null
: DateTime.parse(json['created_at'] as String),
updatedAt: json['updated_at'] == null
? null
: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
accountId: json['account_id'] as String,
account: json['account'] == null
? null
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
relatedId: json['related_id'] as String,
related: json['related'] == null
? null
: SnAccount.fromJson(json['related'] as Map<String, dynamic>),
expiredAt: json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
status: (json['status'] as num).toInt(),
);
Map<String, dynamic> _$SnRelationshipToJson(_SnRelationship instance) =>
<String, dynamic>{
'created_at': instance.createdAt?.toIso8601String(),
'updated_at': instance.updatedAt?.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'account_id': instance.accountId,
'account': instance.account?.toJson(),
'related_id': instance.relatedId,
'related': instance.related?.toJson(),
'expired_at': instance.expiredAt?.toIso8601String(),
'status': instance.status,
};

View File

@@ -0,0 +1,96 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/accounts_models/account.dart';
import 'package:island/core/config.dart';
import 'package:island/core/network.dart';
import 'package:island/talker.dart';
import 'package:island/core/services/analytics_service.dart';
class UserInfoNotifier extends AsyncNotifier<SnAccount?> {
@override
Future<SnAccount?> build() async {
final token = ref.watch(tokenProvider);
if (token == null) {
talker.info('[UserInfo] No token found, not going to fetch...');
return null;
}
return _fetchUser();
}
Future<SnAccount?> _fetchUser() async {
try {
final client = ref.read(apiClientProvider);
final response = await client.get('/pass/accounts/me');
final user = SnAccount.fromJson(response.data);
AnalyticsService().setUserId(user.id);
return user;
} catch (error, stackTrace) {
if (error is DioException) {
if (error.response?.statusCode == 503) return null;
showOverlayDialog<bool>(
builder: (context, close) => AlertDialog(
title: Text('failedToLoadUserInfo'.tr()),
content: Text(
[
(error.response?.statusCode == 401
? 'failedToLoadUserInfoUnauthorized'
: 'failedToLoadUserInfoNetwork')
.tr()
.trim(),
'',
'${error.response?.statusCode ?? 'Network Error'}',
if (error.response?.headers != null) error.response?.headers,
if (error.response?.data != null)
jsonEncode(error.response?.data),
].join('\n'),
),
actions: [
TextButton(
onPressed: () => close(false),
child: Text('okay'.tr()),
),
TextButton(
onPressed: () => close(true),
child: Text('retry'.tr()),
),
],
),
).then((value) {
if (value == true) {
ref.invalidateSelf();
}
});
}
talker.error(
"[UserInfo] Failed to fetch user info...",
error,
stackTrace,
);
return null;
}
}
Future<void> fetchUser() async {
ref.invalidateSelf();
await future;
}
Future<void> logOut() async {
state = const AsyncValue.data(null);
final prefs = ref.read(sharedPreferencesProvider);
await prefs.remove(kTokenPairStoreKey);
ref.invalidate(tokenProvider);
AnalyticsService().setUserId(null);
AnalyticsService().logLogout();
}
}
final userInfoProvider = AsyncNotifierProvider<UserInfoNotifier, SnAccount?>(
UserInfoNotifier.new,
);

View File

@@ -0,0 +1,605 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/leveling_progress.dart';
import 'package:island/accounts/accounts_widgets/account/status.dart';
import 'package:island/core/websocket.dart';
import 'package:island/core/database.dart';
import 'package:island/core/network.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/core/services/responsive.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/debug_sheet.dart';
import 'package:island/notifications/notification.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
class AccountShellScreen extends HookConsumerWidget {
final Widget child;
const AccountShellScreen({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context);
if (isWide) {
return AppBackground(
isRoot: true,
child: Row(
children: [
Flexible(flex: 2, child: AccountScreen(isAside: true)),
VerticalDivider(width: 1),
Flexible(flex: 3, child: child),
],
),
);
}
return AppBackground(isRoot: true, child: child);
}
}
class AccountScreen extends HookConsumerWidget {
final bool isAside;
const AccountScreen({super.key, this.isAside = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context);
if (isWide && !isAside) {
return const EmptyPageHolder();
}
final user = ref.watch(userInfoProvider);
final notificationUnreadCount = ref.watch(notificationUnreadCountProvider);
if (user.value == null || user.value == null) {
return _UnauthorizedAccountScreen();
}
return AppScaffold(
isNoBackground: isWide,
appBar: AppBar(backgroundColor: Colors.transparent, toolbarHeight: 0),
body: SingleChildScrollView(
child: Column(
spacing: 4,
children: <Widget>[
Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (user.value?.profile.background != null)
Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
),
child: AspectRatio(
aspectRatio: 16 / 7,
child: CloudImageWidget(
file: user.value?.profile.background,
fit: BoxFit.cover,
),
),
),
Positioned(
bottom: -24,
left: 16,
child: GestureDetector(
child: ProfilePictureWidget(
file: user.value?.profile.picture,
radius: 32,
),
onTap: () {
context.pushNamed(
'accountProfile',
pathParameters: {'name': user.value!.name},
);
},
),
),
],
),
Builder(
builder: (context) {
final hasBackground =
user.value?.profile.background != null;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: hasBackground ? 0 : 16,
children: [
if (!hasBackground)
GestureDetector(
child: ProfilePictureWidget(
file: user.value?.profile.picture,
radius: 24,
),
onTap: () {
context.pushNamed(
'accountProfile',
pathParameters: {'name': user.value!.name},
);
},
).padding(bottom: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
spacing: 4,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Flexible(
child: AccountName(
account: user.value!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Flexible(
child: Text(
'@${user.value!.name}',
).fontSize(11).padding(bottom: 2.5),
),
],
),
Text(
(user.value!.profile.bio.isNotEmpty)
? user.value!.profile.bio
: 'descriptionNone'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const Gap(12),
],
),
),
],
).padding(
left: 16,
right: 16,
top: 16 + (hasBackground ? 16 : 0),
);
},
),
],
),
).padding(horizontal: 8),
if (user.value?.activatedAt == null)
AccountUnactivatedCard().padding(horizontal: 12, bottom: 4),
Card(
margin: EdgeInsets.zero,
child: Column(
children: [
AccountStatusCreationWidget(uname: user.value!.name),
ActivityPresenceWidget(
uname: user.value!.name,
isCompact: true,
compactPadding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 8,
top: 4,
),
),
],
),
).padding(horizontal: 12, bottom: 4),
LevelingProgressCard(
isCompact: true,
level: user.value!.profile.level,
experience: user.value!.profile.experience,
progress: user.value!.profile.levelingProgress,
onTap: () {
context.pushNamed('leveling');
},
).padding(horizontal: 12),
if (!isWideScreen(context)) const SizedBox.shrink(),
if (!isWideScreen(context))
Row(
spacing: 8,
children: [
Expanded(
child: Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Symbols.draw, size: 28).padding(bottom: 8),
Text(
'creatorHub',
maxLines: 1,
overflow: TextOverflow.ellipsis,
).tr().fontSize(16).bold(),
Text(
'creatorHubDescription',
maxLines: 2,
overflow: TextOverflow.ellipsis,
).tr(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.goNamed('creatorHub');
},
),
).height(140),
),
Expanded(
child: Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Symbols.code, size: 28).padding(bottom: 8),
Text(
'developerPortal',
maxLines: 1,
overflow: TextOverflow.ellipsis,
).tr().fontSize(16).bold(),
Text(
'developerPortalDescription',
maxLines: 2,
overflow: TextOverflow.ellipsis,
).tr(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.goNamed('developerHub');
},
),
).height(140),
),
],
).padding(horizontal: 12),
const SizedBox.shrink(),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width,
),
child: LayoutBuilder(
builder: (context, constraints) {
const minWidth = 160.0;
const spacing = 8.0;
const padding = 24.0; // 12 * 2
final totalMin = 3 * minWidth + 2 * spacing;
final availableWidth = constraints.maxWidth - padding;
final children = [
Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Row(
spacing: 8,
children: [
Icon(Symbols.settings, size: 20),
Text('appSettings').tr().fontSize(13).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('settings');
},
),
),
Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Row(
spacing: 8,
children: [
Icon(Symbols.person_edit, size: 20),
Text('updateYourProfile').tr().fontSize(13).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('profileUpdate');
},
),
),
Card(
margin: EdgeInsets.zero,
child: InkWell(
borderRadius: BorderRadius.circular(8),
child: Row(
spacing: 8,
children: [
Icon(Symbols.manage_accounts, size: 20),
Text('accountSettings').tr().fontSize(13).bold(),
],
).padding(horizontal: 16, vertical: 12),
onTap: () {
context.pushNamed('accountSettings');
},
),
),
];
if (availableWidth > totalMin) {
return Row(
spacing: 8,
children: children
.map((child) => Expanded(child: child))
.toList(),
).padding(horizontal: 12).height(48);
} else {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 8,
children: children
.map(
(child) =>
SizedBox(width: minWidth, child: child),
)
.toList(),
).padding(horizontal: 12),
).height(48);
}
},
),
),
Builder(
builder: (context) {
final menuItems = [
{
'icon': Symbols.notifications,
'title': 'notifications',
'badgeCount': notificationUnreadCount.value ?? 0,
'onTap': () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => const NotificationSheet(),
);
},
},
if (!isWideScreen(context))
{
'icon': Symbols.files,
'title': 'files',
'onTap': () {
context.goNamed('files');
},
},
if (!isWideScreen(context))
{
'icon': Symbols.groups_3,
'title': 'realms',
'onTap': () {
context.goNamed('realmList');
},
},
{
'icon': Symbols.wallet,
'title': 'wallet',
'onTap': () {
context.pushNamed('wallet');
},
},
{
'icon': Symbols.people,
'title': 'relationships',
'onTap': () {
context.pushNamed('relationships');
},
},
{
'icon': Symbols.sticker_rounded,
'title': 'stickers',
'onTap': () {
context.pushNamed('stickerMarketplace');
},
},
{
'icon': Symbols.rss_feed,
'title': 'webFeeds',
'onTap': () {
context.pushNamed('webFeedMarketplace');
},
},
{
'icon': Symbols.gavel,
'title': 'abuseReport',
'onTap': () {
context.pushNamed('reportList');
},
},
{
'icon': Symbols.fitness_center,
'title': 'fitnessActivity',
'onTap': () {
context.pushNamed('fitnessActivity');
},
},
];
return Column(
children: menuItems.map((item) {
final icon = item['icon'] as IconData;
final title = item['title'] as String;
final badgeCount = item['badgeCount'] as int?;
final onTap = item['onTap'] as VoidCallback?;
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
),
trailing: const Icon(Symbols.chevron_right),
dense: true,
leading: Badge(
isLabelVisible: badgeCount != null && badgeCount > 0,
label: Text(badgeCount.toString()),
child: Icon(icon, size: 24),
),
title: Text(title).tr(),
onTap: onTap,
);
}).toList(),
);
},
),
const Divider(height: 1).padding(vertical: 8),
ListTile(
leading: const Icon(Symbols.info),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
dense: true,
title: Text('about').tr(),
onTap: () {
context.pushNamed('about');
},
),
ListTile(
leading: const Icon(Symbols.bug_report),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('debugOptions').tr(),
dense: true,
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => DebugSheet(),
);
},
),
ListTile(
leading: const Icon(Symbols.logout),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('logout').tr(),
dense: true,
onTap: () async {
final ws = ref.watch(websocketStateProvider.notifier);
final apiClient = ref.watch(apiClientProvider);
showLoadingModal(context);
await apiClient.delete('/pass/accounts/me/sessions/current');
await resetDatabase(ref);
if (!context.mounted) return;
hideLoadingModal(context);
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.logOut();
ws.close();
},
),
],
).padding(top: 8, bottom: MediaQuery.of(context).padding.bottom),
),
);
}
}
class _UnauthorizedAccountScreen extends StatelessWidget {
const _UnauthorizedAccountScreen();
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(title: const Text('account').tr()),
body: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Card(
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () {
context.pushNamed('createAccount');
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(Symbols.person_add, size: 48),
const SizedBox(height: 8),
Text('createAccount').tr().bold(),
Text('createAccountDescription').tr(),
],
),
),
),
),
),
const Gap(8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Card(
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
onTap: () {
context.pushNamed('login');
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Icon(Symbols.login, size: 48),
const SizedBox(height: 8),
Text('login').tr().bold(),
Text('loginDescription').tr(),
],
),
),
),
),
),
const Gap(8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: () {
context.pushNamed('about');
},
iconSize: 18,
color: Theme.of(context).colorScheme.secondary,
icon: const Icon(Icons.info, fill: 1),
tooltip: 'about'.tr(),
),
IconButton(
icon: const Icon(Icons.bug_report, fill: 1),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (context) => DebugSheet(),
);
},
iconSize: 18,
color: Theme.of(context).colorScheme.secondary,
tooltip: 'debugOptions'.tr(),
),
IconButton(
onPressed: () {
context.pushNamed('settings');
},
icon: const Icon(Icons.settings, fill: 1),
iconSize: 18,
color: Theme.of(context).colorScheme.secondary,
tooltip: 'appSettings'.tr(),
),
],
),
],
),
).center(),
);
}
}

View 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(),
),
),
],
),
);
}
}

View 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';

View 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),
);
}
}

View 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()),
],
),
),
],
),
),
),
),
);
}
}

View 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,
);
}

View 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')),
);
},
),
),
],
),
);
}
}

View 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';
}

View 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')),
);
},
);
}
}

View 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';
}

View 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,
),
),
);
}
}

View 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;
}
}

View File

@@ -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),
],
),
);
}
}
}

View 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);
}
}

View 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
}
}

View 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();
}
}

View File

@@ -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';

View 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;
}
}

View File

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

View 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),
),
],
);
}
}

View 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';
}

View 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),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View 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';
}

View File

@@ -0,0 +1 @@
export 'user_list_item.dart';

View 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,
);
}
}

View 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,
),
),
],
);
}
}

View 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,
);
}
}

294
lib/accounts/check_in.dart Normal file
View File

@@ -0,0 +1,294 @@
import 'dart:async';
import 'dart:convert';
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_widgets/account/event_calendar_content.dart';
import 'package:island/core/models/activity.dart';
import 'package:island/accounts/accounts_models/fortune.dart';
import 'package:island/core/network.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/auth/captcha.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'check_in.g.dart';
@riverpod
Future<SnCheckInResult?> checkInResultToday(Ref ref) async {
final client = ref.watch(apiClientProvider);
try {
final resp = await client.get('/pass/accounts/me/check-in');
return SnCheckInResult.fromJson(resp.data);
} catch (err) {
if (err is DioException) {
if (err.response?.statusCode == 404) {
return null;
}
}
rethrow;
}
}
@riverpod
Future<SnNotableDay?> nextNotableDay(Ref ref) async {
final client = ref.watch(apiClientProvider);
try {
final resp = await client.get('/pass/notable/me/next');
final day = SnNotableDay.fromJson(resp.data);
if (day.localizableKey != null) {
final key = 'notableDay${day.localizableKey}';
if (key.trExists()) {
return day.copyWith(
localName: key.tr(),
date: day.date.toLocal().copyWith(hour: 0, second: 0),
);
}
}
return day.copyWith(date: day.date.toLocal().copyWith(hour: 0, second: 0));
} catch (err) {
return null;
}
}
@riverpod
Future<SnNotableDay?> recentNotableDay(Ref ref) async {
final client = ref.watch(apiClientProvider);
try {
final resp = await client.get('/pass/notable/me/recent');
final day = SnNotableDay.fromJson(resp.data[0]);
if (day.localizableKey != null) {
final key = 'notableDay${day.localizableKey}';
if (key.trExists()) {
return day.copyWith(
localName: key.tr(),
date: day.date.toLocal().copyWith(hour: 0, second: 0),
);
}
}
return day.copyWith(date: day.date.toLocal().copyWith(hour: 0, second: 0));
} catch (err) {
return null;
}
}
@riverpod
Future<SnFortuneSaying> randomFortuneSaying(Ref ref) async {
final client = ref.watch(apiClientProvider);
final resp = await client.get('/pass/fortune/random');
return SnFortuneSaying.fromJson(resp.data[0]);
}
class CheckInWidget extends HookConsumerWidget {
final EdgeInsets? margin;
final VoidCallback? onChecked;
const CheckInWidget({super.key, this.margin, this.onChecked});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todayResult = ref.watch(checkInResultTodayProvider);
// Update time every second for live progress
final currentTime = useState(DateTime.now());
useEffect(() {
final timer = Timer.periodic(const Duration(seconds: 1), (_) {
currentTime.value = DateTime.now();
});
return timer.cancel;
}, []);
Future<void> checkIn({String? captchatTk}) async {
final client = ref.read(apiClientProvider);
try {
await client.post(
'/pass/accounts/me/check-in',
data: captchatTk == null ? null : jsonEncode(captchatTk),
);
ref.invalidate(checkInResultTodayProvider);
final userNotifier = ref.read(userInfoProvider.notifier);
userNotifier.fetchUser();
onChecked?.call();
} catch (err) {
if (err is DioException) {
if (err.response?.statusCode == 423 && context.mounted) {
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
return await checkIn(captchatTk: captchaTk);
}
}
showErrorAlert(err);
}
}
return Card(
margin:
margin ?? EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: todayResult.when(
data: (result) {
return Text(
result == null
? 'checkInNone'
: 'checkInResultLevel${result.level}',
textAlign: TextAlign.start,
).tr().fontSize(15).bold();
},
loading: () => Text('checkInNone').tr().fontSize(15).bold(),
error: (err, stack) =>
Text('error').tr().fontSize(15).bold(),
),
).padding(right: 4),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: todayResult.when(
data: (result) {
if (result == null) {
return Text('checkInNoneHint').tr().fontSize(11);
}
return Wrap(
alignment: WrapAlignment.start,
runAlignment: WrapAlignment.start,
children:
result.tips
.map((e) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
e.isPositive
? Symbols.thumb_up
: Symbols.thumb_down,
size: 12,
),
const Gap(4),
Text(e.title).fontSize(11),
],
);
})
.toList()
.expand(
(widget) => [
widget,
Text(' · ').fontSize(11),
],
)
.toList()
..removeLast(),
);
},
loading: () => Text('checkInNoneHint').tr().fontSize(11),
error: (err, stack) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('error').tr().fontSize(15).bold(),
Text(err.toString()).fontSize(11),
],
),
),
).alignment(Alignment.centerLeft),
],
),
),
Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 4,
children: [
IconButton.outlined(
iconSize: 16,
visualDensity: const VisualDensity(
horizontal: -3,
vertical: -2,
),
onPressed: () {
if (todayResult.value == null) {
checkIn();
} else {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SheetScaffold(
titleText: 'eventCalendar'.tr(),
child: EventCalendarContent(name: 'me', isSheet: true),
),
);
}
},
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: todayResult.when(
data: (result) => Icon(
result == null
? Symbols.local_fire_department
: Symbols.event,
key: ValueKey(result != null),
),
loading: () => const Icon(Symbols.refresh),
error: (_, _) => const Icon(Symbols.error),
),
),
),
],
),
],
).padding(horizontal: 16, vertical: 12),
);
}
}
class CheckInActivityWidget extends StatelessWidget {
final SnTimelineEvent item;
const CheckInActivityWidget({super.key, required this.item});
@override
Widget build(BuildContext context) {
final result = SnCheckInResult.fromJson(item.data);
return Row(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfilePictureWidget(file: result.account!.profile.picture, radius: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Symbols.local_fire_department, size: 14),
const Gap(4),
Text('checkIn').fontSize(11).tr(),
],
).opacity(0.85),
Text('checkInActivityTitle')
.tr(
args: [
result.account!.nick,
DateFormat.yMd().format(result.createdAt),
'checkInResultLevel${result.level}'.tr(),
],
)
.fontSize(13)
.padding(left: 2),
],
),
),
],
).padding(horizontal: 16, vertical: 12);
}
}

View File

@@ -0,0 +1,168 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'check_in.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(checkInResultToday)
final checkInResultTodayProvider = CheckInResultTodayProvider._();
final class CheckInResultTodayProvider
extends
$FunctionalProvider<
AsyncValue<SnCheckInResult?>,
SnCheckInResult?,
FutureOr<SnCheckInResult?>
>
with $FutureModifier<SnCheckInResult?>, $FutureProvider<SnCheckInResult?> {
CheckInResultTodayProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'checkInResultTodayProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$checkInResultTodayHash();
@$internal
@override
$FutureProviderElement<SnCheckInResult?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnCheckInResult?> create(Ref ref) {
return checkInResultToday(ref);
}
}
String _$checkInResultTodayHash() =>
r'b4dc97b2243f542b36c295dc5cce3fe6097cb308';
@ProviderFor(nextNotableDay)
final nextNotableDayProvider = NextNotableDayProvider._();
final class NextNotableDayProvider
extends
$FunctionalProvider<
AsyncValue<SnNotableDay?>,
SnNotableDay?,
FutureOr<SnNotableDay?>
>
with $FutureModifier<SnNotableDay?>, $FutureProvider<SnNotableDay?> {
NextNotableDayProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'nextNotableDayProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$nextNotableDayHash();
@$internal
@override
$FutureProviderElement<SnNotableDay?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnNotableDay?> create(Ref ref) {
return nextNotableDay(ref);
}
}
String _$nextNotableDayHash() => r'60d0546a086bdcb89c433c38133eb4197e4fb0a6';
@ProviderFor(recentNotableDay)
final recentNotableDayProvider = RecentNotableDayProvider._();
final class RecentNotableDayProvider
extends
$FunctionalProvider<
AsyncValue<SnNotableDay?>,
SnNotableDay?,
FutureOr<SnNotableDay?>
>
with $FutureModifier<SnNotableDay?>, $FutureProvider<SnNotableDay?> {
RecentNotableDayProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'recentNotableDayProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$recentNotableDayHash();
@$internal
@override
$FutureProviderElement<SnNotableDay?> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnNotableDay?> create(Ref ref) {
return recentNotableDay(ref);
}
}
String _$recentNotableDayHash() => r'e0cc4a0e8016afe1c469a7c744dbab41e0d54c2d';
@ProviderFor(randomFortuneSaying)
final randomFortuneSayingProvider = RandomFortuneSayingProvider._();
final class RandomFortuneSayingProvider
extends
$FunctionalProvider<
AsyncValue<SnFortuneSaying>,
SnFortuneSaying,
FutureOr<SnFortuneSaying>
>
with $FutureModifier<SnFortuneSaying>, $FutureProvider<SnFortuneSaying> {
RandomFortuneSayingProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'randomFortuneSayingProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$randomFortuneSayingHash();
@$internal
@override
$FutureProviderElement<SnFortuneSaying> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<SnFortuneSaying> create(Ref ref) {
return randomFortuneSaying(ref);
}
}
String _$randomFortuneSayingHash() =>
r'861378dba8021e8555b568fb8e0390b2b24056f6';

View File

@@ -0,0 +1,53 @@
import 'package:island/core/models/activity.dart';
import 'package:island/core/network.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'event_calendar.g.dart';
/// Query parameters for fetching event calendar data
class EventCalendarQuery {
/// Username to fetch calendar for, null means current user ('me')
final String? uname;
/// Year to fetch calendar for
final int year;
/// Month to fetch calendar for
final int month;
const EventCalendarQuery({
required this.uname,
required this.year,
required this.month,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is EventCalendarQuery &&
runtimeType == other.runtimeType &&
uname == other.uname &&
year == other.year &&
month == other.month;
@override
int get hashCode => uname.hashCode ^ year.hashCode ^ month.hashCode;
}
/// Provider for fetching event calendar data
/// This can be used anywhere in the app where calendar data is needed
@riverpod
Future<List<SnEventCalendarEntry>> eventCalendar(
Ref ref,
EventCalendarQuery query,
) async {
final client = ref.watch(apiClientProvider);
final resp = await client.get(
'/pass/accounts/${query.uname ?? 'me'}/calendar',
queryParameters: {'year': query.year, 'month': query.month},
);
return resp.data
.map((e) => SnEventCalendarEntry.fromJson(e))
.cast<SnEventCalendarEntry>()
.toList();
}

View File

@@ -0,0 +1,104 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'event_calendar.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for fetching event calendar data
/// This can be used anywhere in the app where calendar data is needed
@ProviderFor(eventCalendar)
final eventCalendarProvider = EventCalendarFamily._();
/// Provider for fetching event calendar data
/// This can be used anywhere in the app where calendar data is needed
final class EventCalendarProvider
extends
$FunctionalProvider<
AsyncValue<List<SnEventCalendarEntry>>,
List<SnEventCalendarEntry>,
FutureOr<List<SnEventCalendarEntry>>
>
with
$FutureModifier<List<SnEventCalendarEntry>>,
$FutureProvider<List<SnEventCalendarEntry>> {
/// Provider for fetching event calendar data
/// This can be used anywhere in the app where calendar data is needed
EventCalendarProvider._({
required EventCalendarFamily super.from,
required EventCalendarQuery super.argument,
}) : super(
retry: null,
name: r'eventCalendarProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$eventCalendarHash();
@override
String toString() {
return r'eventCalendarProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<List<SnEventCalendarEntry>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnEventCalendarEntry>> create(Ref ref) {
final argument = this.argument as EventCalendarQuery;
return eventCalendar(ref, argument);
}
@override
bool operator ==(Object other) {
return other is EventCalendarProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$eventCalendarHash() => r'3a33581c28bcd44bc5eb3abdb770171b4d275a5d';
/// Provider for fetching event calendar data
/// This can be used anywhere in the app where calendar data is needed
final class EventCalendarFamily extends $Family
with
$FunctionalFamilyOverride<
FutureOr<List<SnEventCalendarEntry>>,
EventCalendarQuery
> {
EventCalendarFamily._()
: super(
retry: null,
name: r'eventCalendarProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
/// Provider for fetching event calendar data
/// This can be used anywhere in the app where calendar data is needed
EventCalendarProvider call(EventCalendarQuery query) =>
EventCalendarProvider._(argument: query, from: this);
@override
String toString() => r'eventCalendarProvider';
}

View File

@@ -0,0 +1,200 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/core/utils/format.dart';
import 'package:styled_widget/styled_widget.dart';
class UsageOverviewWidget extends StatelessWidget {
final Map<String, dynamic>? usage;
final Map<String, dynamic>? quota;
const UsageOverviewWidget({
super.key,
required this.usage,
required this.quota,
});
@override
Widget build(BuildContext context) {
if (usage == null) return const SizedBox.shrink();
final nonNullUsage = usage!;
return Column(
children: [
Column(
children: [
Row(
children: [
Expanded(
child: _buildStatCard(
'All Uploads',
formatFileSize(nonNullUsage['total_usage_bytes'] as int),
),
),
Expanded(
child: _buildStatCard(
'All Files',
'${nonNullUsage['total_file_count']}',
),
),
],
),
Row(
children: [
Expanded(
child: _buildStatCard(
'Quota',
formatFileSize(
(nonNullUsage['total_quota'] as int) * 1024 * 1024,
),
),
),
Expanded(
child: _buildStatCard(
'Used Quota',
'${((nonNullUsage['used_quota'] as num) / (nonNullUsage['total_quota'] as num) * 100).toStringAsFixed(2)}%',
progress:
(nonNullUsage['used_quota'] as num) /
(nonNullUsage['total_quota'] as num),
),
),
],
),
],
).padding(horizontal: 8),
const Gap(8),
Row(
children: [
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text('Pool Usage'),
SizedBox(
height: 200,
child: PieChart(_buildPoolChartData(nonNullUsage)),
),
],
),
),
),
),
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
const Text('Verbose Quota'),
SizedBox(
height: 200,
child: PieChart(_buildQuotaChartData(quota)),
),
],
),
),
),
),
],
).padding(horizontal: 8),
],
);
}
PieChartData _buildPoolChartData(Map<String, dynamic> usage) {
final pools = usage['pool_usages'] as List<dynamic>;
final colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.red,
Colors.purple,
];
return PieChartData(
sections:
pools.asMap().entries.map((entry) {
final pool = entry.value as Map<String, dynamic>;
final title = pool['pool_name'] as String;
final truncatedTitle =
title.length > 8 ? '${title.substring(0, 8)}...' : title;
return PieChartSectionData(
value: (pool['usage_bytes'] as num).toDouble(),
title: truncatedTitle,
color: colors[entry.key % colors.length],
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}).toList(),
);
}
PieChartData _buildQuotaChartData(Map<String, dynamic>? quota) {
if (quota == null) return PieChartData(sections: []);
return PieChartData(
sections: [
PieChartSectionData(
value: (quota['based_quota'] as num).toDouble(),
title: 'Base',
color: Colors.green,
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
PieChartSectionData(
value: (quota['extra_quota'] as num).toDouble(),
title: 'Extra',
color: Colors.orange,
radius: 60,
titleStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
);
}
Widget _buildStatCard(String label, String value, {double? progress}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: const TextStyle(fontSize: 14)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
if (progress != null) ...[
const SizedBox(height: 8),
SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(value: progress),
),
],
],
),
],
),
),
);
}
}