About page

This commit is contained in:
2025-07-02 02:20:31 +08:00
parent b55e56c3c4
commit 8e903ec6c1
11 changed files with 931 additions and 4 deletions

View File

@ -0,0 +1,34 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auto_completion.freezed.dart';
part 'auto_completion.g.dart';
@freezed
sealed class AutoCompletionResponse with _$AutoCompletionResponse {
const factory AutoCompletionResponse.account({
required String type,
required List<AutoCompletionItem> items,
}) = AutoCompletionAccountResponse;
const factory AutoCompletionResponse.sticker({
required String type,
required List<AutoCompletionItem> items,
}) = AutoCompletionStickerResponse;
factory AutoCompletionResponse.fromJson(Map<String, dynamic> json) =>
_$AutoCompletionResponseFromJson(json);
}
@freezed
sealed class AutoCompletionItem with _$AutoCompletionItem {
const factory AutoCompletionItem({
required String id,
required String displayName,
required String? secondaryText,
required String type,
required dynamic data,
}) = _AutoCompletionItem;
factory AutoCompletionItem.fromJson(Map<String, dynamic> json) =>
_$AutoCompletionItemFromJson(json);
}

View File

@ -0,0 +1,410 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// 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 'auto_completion.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
AutoCompletionResponse _$AutoCompletionResponseFromJson(
Map<String, dynamic> json
) {
switch (json['runtimeType']) {
case 'account':
return AutoCompletionAccountResponse.fromJson(
json
);
case 'sticker':
return AutoCompletionStickerResponse.fromJson(
json
);
default:
throw CheckedFromJsonException(
json,
'runtimeType',
'AutoCompletionResponse',
'Invalid union type "${json['runtimeType']}"!'
);
}
}
/// @nodoc
mixin _$AutoCompletionResponse {
String get type; List<AutoCompletionItem> get items;
/// Create a copy of AutoCompletionResponse
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$AutoCompletionResponseCopyWith<AutoCompletionResponse> get copyWith => _$AutoCompletionResponseCopyWithImpl<AutoCompletionResponse>(this as AutoCompletionResponse, _$identity);
/// Serializes this AutoCompletionResponse to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AutoCompletionResponse&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.items, items));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,type,const DeepCollectionEquality().hash(items));
@override
String toString() {
return 'AutoCompletionResponse(type: $type, items: $items)';
}
}
/// @nodoc
abstract mixin class $AutoCompletionResponseCopyWith<$Res> {
factory $AutoCompletionResponseCopyWith(AutoCompletionResponse value, $Res Function(AutoCompletionResponse) _then) = _$AutoCompletionResponseCopyWithImpl;
@useResult
$Res call({
String type, List<AutoCompletionItem> items
});
}
/// @nodoc
class _$AutoCompletionResponseCopyWithImpl<$Res>
implements $AutoCompletionResponseCopyWith<$Res> {
_$AutoCompletionResponseCopyWithImpl(this._self, this._then);
final AutoCompletionResponse _self;
final $Res Function(AutoCompletionResponse) _then;
/// Create a copy of AutoCompletionResponse
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? items = null,}) {
return _then(_self.copyWith(
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable
as List<AutoCompletionItem>,
));
}
}
/// @nodoc
@JsonSerializable()
class AutoCompletionAccountResponse implements AutoCompletionResponse {
const AutoCompletionAccountResponse({required this.type, required final List<AutoCompletionItem> items, final String? $type}): _items = items,$type = $type ?? 'account';
factory AutoCompletionAccountResponse.fromJson(Map<String, dynamic> json) => _$AutoCompletionAccountResponseFromJson(json);
@override final String type;
final List<AutoCompletionItem> _items;
@override List<AutoCompletionItem> get items {
if (_items is EqualUnmodifiableListView) return _items;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_items);
}
@JsonKey(name: 'runtimeType')
final String $type;
/// Create a copy of AutoCompletionResponse
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$AutoCompletionAccountResponseCopyWith<AutoCompletionAccountResponse> get copyWith => _$AutoCompletionAccountResponseCopyWithImpl<AutoCompletionAccountResponse>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$AutoCompletionAccountResponseToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AutoCompletionAccountResponse&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._items, _items));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,type,const DeepCollectionEquality().hash(_items));
@override
String toString() {
return 'AutoCompletionResponse.account(type: $type, items: $items)';
}
}
/// @nodoc
abstract mixin class $AutoCompletionAccountResponseCopyWith<$Res> implements $AutoCompletionResponseCopyWith<$Res> {
factory $AutoCompletionAccountResponseCopyWith(AutoCompletionAccountResponse value, $Res Function(AutoCompletionAccountResponse) _then) = _$AutoCompletionAccountResponseCopyWithImpl;
@override @useResult
$Res call({
String type, List<AutoCompletionItem> items
});
}
/// @nodoc
class _$AutoCompletionAccountResponseCopyWithImpl<$Res>
implements $AutoCompletionAccountResponseCopyWith<$Res> {
_$AutoCompletionAccountResponseCopyWithImpl(this._self, this._then);
final AutoCompletionAccountResponse _self;
final $Res Function(AutoCompletionAccountResponse) _then;
/// Create a copy of AutoCompletionResponse
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? items = null,}) {
return _then(AutoCompletionAccountResponse(
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable
as List<AutoCompletionItem>,
));
}
}
/// @nodoc
@JsonSerializable()
class AutoCompletionStickerResponse implements AutoCompletionResponse {
const AutoCompletionStickerResponse({required this.type, required final List<AutoCompletionItem> items, final String? $type}): _items = items,$type = $type ?? 'sticker';
factory AutoCompletionStickerResponse.fromJson(Map<String, dynamic> json) => _$AutoCompletionStickerResponseFromJson(json);
@override final String type;
final List<AutoCompletionItem> _items;
@override List<AutoCompletionItem> get items {
if (_items is EqualUnmodifiableListView) return _items;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_items);
}
@JsonKey(name: 'runtimeType')
final String $type;
/// Create a copy of AutoCompletionResponse
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$AutoCompletionStickerResponseCopyWith<AutoCompletionStickerResponse> get copyWith => _$AutoCompletionStickerResponseCopyWithImpl<AutoCompletionStickerResponse>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$AutoCompletionStickerResponseToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AutoCompletionStickerResponse&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other._items, _items));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,type,const DeepCollectionEquality().hash(_items));
@override
String toString() {
return 'AutoCompletionResponse.sticker(type: $type, items: $items)';
}
}
/// @nodoc
abstract mixin class $AutoCompletionStickerResponseCopyWith<$Res> implements $AutoCompletionResponseCopyWith<$Res> {
factory $AutoCompletionStickerResponseCopyWith(AutoCompletionStickerResponse value, $Res Function(AutoCompletionStickerResponse) _then) = _$AutoCompletionStickerResponseCopyWithImpl;
@override @useResult
$Res call({
String type, List<AutoCompletionItem> items
});
}
/// @nodoc
class _$AutoCompletionStickerResponseCopyWithImpl<$Res>
implements $AutoCompletionStickerResponseCopyWith<$Res> {
_$AutoCompletionStickerResponseCopyWithImpl(this._self, this._then);
final AutoCompletionStickerResponse _self;
final $Res Function(AutoCompletionStickerResponse) _then;
/// Create a copy of AutoCompletionResponse
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? items = null,}) {
return _then(AutoCompletionStickerResponse(
type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable
as List<AutoCompletionItem>,
));
}
}
/// @nodoc
mixin _$AutoCompletionItem {
String get id; String get displayName; String? get secondaryText; String get type; dynamic get data;
/// Create a copy of AutoCompletionItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$AutoCompletionItemCopyWith<AutoCompletionItem> get copyWith => _$AutoCompletionItemCopyWithImpl<AutoCompletionItem>(this as AutoCompletionItem, _$identity);
/// Serializes this AutoCompletionItem to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is AutoCompletionItem&&(identical(other.id, id) || other.id == id)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.secondaryText, secondaryText) || other.secondaryText == secondaryText)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.data, data));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,displayName,secondaryText,type,const DeepCollectionEquality().hash(data));
@override
String toString() {
return 'AutoCompletionItem(id: $id, displayName: $displayName, secondaryText: $secondaryText, type: $type, data: $data)';
}
}
/// @nodoc
abstract mixin class $AutoCompletionItemCopyWith<$Res> {
factory $AutoCompletionItemCopyWith(AutoCompletionItem value, $Res Function(AutoCompletionItem) _then) = _$AutoCompletionItemCopyWithImpl;
@useResult
$Res call({
String id, String displayName, String? secondaryText, String type, dynamic data
});
}
/// @nodoc
class _$AutoCompletionItemCopyWithImpl<$Res>
implements $AutoCompletionItemCopyWith<$Res> {
_$AutoCompletionItemCopyWithImpl(this._self, this._then);
final AutoCompletionItem _self;
final $Res Function(AutoCompletionItem) _then;
/// Create a copy of AutoCompletionItem
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? displayName = null,Object? secondaryText = freezed,Object? type = null,Object? data = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,secondaryText: freezed == secondaryText ? _self.secondaryText : secondaryText // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as dynamic,
));
}
}
/// @nodoc
@JsonSerializable()
class _AutoCompletionItem implements AutoCompletionItem {
const _AutoCompletionItem({required this.id, required this.displayName, required this.secondaryText, required this.type, required this.data});
factory _AutoCompletionItem.fromJson(Map<String, dynamic> json) => _$AutoCompletionItemFromJson(json);
@override final String id;
@override final String displayName;
@override final String? secondaryText;
@override final String type;
@override final dynamic data;
/// Create a copy of AutoCompletionItem
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$AutoCompletionItemCopyWith<_AutoCompletionItem> get copyWith => __$AutoCompletionItemCopyWithImpl<_AutoCompletionItem>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$AutoCompletionItemToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AutoCompletionItem&&(identical(other.id, id) || other.id == id)&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.secondaryText, secondaryText) || other.secondaryText == secondaryText)&&(identical(other.type, type) || other.type == type)&&const DeepCollectionEquality().equals(other.data, data));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,displayName,secondaryText,type,const DeepCollectionEquality().hash(data));
@override
String toString() {
return 'AutoCompletionItem(id: $id, displayName: $displayName, secondaryText: $secondaryText, type: $type, data: $data)';
}
}
/// @nodoc
abstract mixin class _$AutoCompletionItemCopyWith<$Res> implements $AutoCompletionItemCopyWith<$Res> {
factory _$AutoCompletionItemCopyWith(_AutoCompletionItem value, $Res Function(_AutoCompletionItem) _then) = __$AutoCompletionItemCopyWithImpl;
@override @useResult
$Res call({
String id, String displayName, String? secondaryText, String type, dynamic data
});
}
/// @nodoc
class __$AutoCompletionItemCopyWithImpl<$Res>
implements _$AutoCompletionItemCopyWith<$Res> {
__$AutoCompletionItemCopyWithImpl(this._self, this._then);
final _AutoCompletionItem _self;
final $Res Function(_AutoCompletionItem) _then;
/// Create a copy of AutoCompletionItem
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? displayName = null,Object? secondaryText = freezed,Object? type = null,Object? data = freezed,}) {
return _then(_AutoCompletionItem(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,secondaryText: freezed == secondaryText ? _self.secondaryText : secondaryText // ignore: cast_nullable_to_non_nullable
as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
as String,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
as dynamic,
));
}
}
// dart format on

View File

@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auto_completion.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AutoCompletionAccountResponse _$AutoCompletionAccountResponseFromJson(
Map<String, dynamic> json,
) => AutoCompletionAccountResponse(
type: json['type'] as String,
items:
(json['items'] as List<dynamic>)
.map((e) => AutoCompletionItem.fromJson(e as Map<String, dynamic>))
.toList(),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$AutoCompletionAccountResponseToJson(
AutoCompletionAccountResponse instance,
) => <String, dynamic>{
'type': instance.type,
'items': instance.items.map((e) => e.toJson()).toList(),
'runtimeType': instance.$type,
};
AutoCompletionStickerResponse _$AutoCompletionStickerResponseFromJson(
Map<String, dynamic> json,
) => AutoCompletionStickerResponse(
type: json['type'] as String,
items:
(json['items'] as List<dynamic>)
.map((e) => AutoCompletionItem.fromJson(e as Map<String, dynamic>))
.toList(),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$AutoCompletionStickerResponseToJson(
AutoCompletionStickerResponse instance,
) => <String, dynamic>{
'type': instance.type,
'items': instance.items.map((e) => e.toJson()).toList(),
'runtimeType': instance.$type,
};
_AutoCompletionItem _$AutoCompletionItemFromJson(Map<String, dynamic> json) =>
_AutoCompletionItem(
id: json['id'] as String,
displayName: json['display_name'] as String,
secondaryText: json['secondary_text'] as String?,
type: json['type'] as String,
data: json['data'],
);
Map<String, dynamic> _$AutoCompletionItemToJson(_AutoCompletionItem instance) =>
<String, dynamic>{
'id': instance.id,
'display_name': instance.displayName,
'secondary_text': instance.secondaryText,
'type': instance.type,
'data': instance.data,
};

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/screens/about.dart';
import 'package:island/screens/developers/apps.dart';
import 'package:island/screens/developers/edit_app.dart';
import 'package:island/screens/developers/new_app.dart';
@ -249,6 +250,10 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/about',
builder: (context, state) => const AboutScreen(),
),
// Main tabs with TabsScreen shell
ShellRoute(

300
lib/screens/about.dart Normal file
View File

@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher.dart';
class AboutScreen extends StatefulWidget {
const AboutScreen({super.key});
@override
State<AboutScreen> createState() => _AboutScreenState();
}
class _AboutScreenState extends State<AboutScreen> {
PackageInfo _packageInfo = PackageInfo(
appName: 'Island',
packageName: 'com.example.island',
version: '1.0.0',
buildNumber: '1',
);
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_initPackageInfo();
}
Future<void> _initPackageInfo() async {
try {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_packageInfo = info;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Failed to load package info: $e';
_isLoading = false;
});
}
}
}
Future<void> _launchURL(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('About'), elevation: 0),
body:
_isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Text(_errorMessage!))
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 24),
// App Icon and Name
CircleAvatar(
radius: 50,
backgroundColor: theme.colorScheme.primary.withOpacity(
0.1,
),
child: Image.asset(
'assets/icons/icon.png',
width: 56,
height: 56,
),
),
const SizedBox(height: 16),
Text(
_packageInfo.appName,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'Version ${_packageInfo.version} (${_packageInfo.buildNumber})',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.textTheme.bodySmall?.color,
),
),
const SizedBox(height: 32),
// App Info Card
_buildSection(
context,
title: 'App Information',
children: [
_buildInfoItem(
context,
icon: Icons.info_outline,
label: 'Package Name',
value: _packageInfo.packageName,
),
_buildInfoItem(
context,
icon: Icons.update,
label: 'Version',
value: _packageInfo.version,
),
_buildInfoItem(
context,
icon: Icons.build,
label: 'Build Number',
value: _packageInfo.buildNumber,
),
],
),
const SizedBox(height: 16),
// Links Card
_buildSection(
context,
title: 'Links',
children: [
_buildListTile(
context,
icon: Icons.privacy_tip_outlined,
title: 'Privacy Policy',
onTap:
() => _launchURL(
'https://solsynth.dev/terms/privacy-policy',
),
),
_buildListTile(
context,
icon: Icons.description_outlined,
title: 'Terms of Service',
onTap:
() => _launchURL(
'https://example.com/terms/basic-law',
),
),
_buildListTile(
context,
icon: Icons.code,
title: 'Open Source Licenses',
onTap: () {
showLicensePage(
context: context,
applicationName: _packageInfo.appName,
applicationVersion:
'Version ${_packageInfo.version}',
);
},
),
],
),
const SizedBox(height: 16),
// Developer Info
_buildSection(
context,
title: 'Developer',
children: [
_buildListTile(
context,
icon: Icons.email_outlined,
title: 'Contact Us',
subtitle: 'lily@solsynth.dev',
onTap: () => _launchURL('mailto:lily@solsynth.dev'),
),
_buildListTile(
context,
icon: Icons.copyright,
title: 'License',
subtitle:
'Copyright reserved © ${DateTime.now().year} Solsynth\nGNU Affero General Public License v3.0',
onTap:
() => _launchURL(
'https://github.com/Solsynth/Solian/blob/v3/LICENSE.txt',
),
),
],
),
const SizedBox(height: 32),
// Copyright
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'© ${DateTime.now().year} ${_packageInfo.appName}. All rights reserved.',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
],
),
),
);
}
Widget _buildSection(
BuildContext context, {
required String title,
required List<Widget> children,
}) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
const Divider(height: 1),
...children,
],
),
);
}
Widget _buildInfoItem(
BuildContext context, {
required IconData icon,
required String label,
required String value,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, size: 20, color: Theme.of(context).hintColor),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 2),
SelectableText(
value,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
if (value.startsWith('http') || value.contains('@'))
IconButton(
icon: const Icon(Icons.copy, size: 16),
onPressed: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied to clipboard')),
);
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'Copy to clipboard',
),
],
),
);
}
Widget _buildListTile(
BuildContext context, {
required IconData icon,
required String title,
String? subtitle,
required VoidCallback onTap,
}) {
return Column(
children: [
ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle) : null,
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
minLeadingWidth: 24,
),
],
);
}
}

View File

@ -281,6 +281,16 @@ class AccountScreen extends HookConsumerWidget {
},
),
const Divider(height: 1).padding(vertical: 8),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.info),
trailing: const Icon(Symbols.chevron_right),
contentPadding: EdgeInsets.symmetric(horizontal: 24),
title: Text('about').tr(),
onTap: () {
context.push('/about');
},
),
ListTile(
minTileHeight: 48,
leading: const Icon(Symbols.logout),

View File

@ -81,7 +81,10 @@ class WebArticleCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (showDetails) const SizedBox(height: 8),
if (showDetails)
const SizedBox(height: 8)
else
Spacer(),
Text(
article.title,
style: theme.textTheme.titleSmall?.copyWith(
@ -104,7 +107,7 @@ class WebArticleCard extends StatelessWidget {
),
),
],
const Spacer(),
if (showDetails) const Spacer(),
if (showDetails && article.publishedAt != null) ...[
Text(
'${article.publishedAt!.formatSystem()} · ${article.publishedAt!.formatRelative(context)}',