🎨 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,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';