Profile decoration

This commit is contained in:
2025-12-10 23:11:46 +08:00
parent d7746d14e4
commit d8485954fa
4 changed files with 577 additions and 174 deletions

View File

@@ -9,8 +9,11 @@ import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/services/time.dart';
import 'package:island/utils/format.dart';
import 'package:island/widgets/content/profile_decoration.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:island/widgets/data_saving_gate.dart';
import 'file_viewer_contents.dart';
@@ -258,17 +261,15 @@ class CloudFileWidget extends HookConsumerWidget {
var content = switch (item.mimeType?.split('/').firstOrNull) {
'image' => AspectRatio(
aspectRatio: ratio,
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
child: (useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
),
'video' => AspectRatio(
aspectRatio: ratio,
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.play_arrow)
: cloudVideo(),
child: (useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.play_arrow)
: cloudVideo(),
),
'audio' => AudioFileContent(item: item, uri: uri),
_ => Builder(
@@ -383,10 +384,9 @@ class CloudVideoWidget extends HookConsumerWidget {
final serverUrl = ref.watch(serverUrlProvider);
final uri = '$serverUrl/drive/files/${item.id}';
var ratio =
item.fileMeta?['ratio'] is num
? item.fileMeta!['ratio'].toDouble()
: 1.0;
var ratio = item.fileMeta?['ratio'] is num
? item.fileMeta!['ratio'].toDouble()
: 1.0;
if (ratio == 0) ratio = 1.0;
if (open.value) {
@@ -533,10 +533,9 @@ class CloudImageWidget extends ConsumerWidget {
return AspectRatio(
aspectRatio: aspectRatio,
child:
file != null
? CloudFileWidget(item: file!, fit: fit)
: UniversalImage(uri: uri, blurHash: blurHash, fit: fit),
child: file != null
? CloudFileWidget(item: file!, fit: fit)
: UniversalImage(uri: uri, blurHash: blurHash, fit: fit),
);
}
@@ -545,10 +544,9 @@ class CloudImageWidget extends ConsumerWidget {
required String serverUrl,
bool original = false,
}) {
final uri =
original
? '$serverUrl/drive/files/$fileId?original=true'
: '$serverUrl/drive/files/$fileId';
final uri = original
? '$serverUrl/drive/files/$fileId?original=true'
: '$serverUrl/drive/files/$fileId';
return CachedNetworkImageProvider(uri);
}
}
@@ -560,6 +558,7 @@ class ProfilePictureWidget extends ConsumerWidget {
final double? borderRadius;
final IconData? fallbackIcon;
final Color? fallbackColor;
final ProfileDecoration? decoration;
const ProfilePictureWidget({
super.key,
this.fileId,
@@ -568,6 +567,7 @@ class ProfilePictureWidget extends ConsumerWidget {
this.borderRadius,
this.fallbackIcon,
this.fallbackColor,
this.decoration,
});
@override
@@ -575,36 +575,49 @@ class ProfilePictureWidget extends ConsumerWidget {
final serverUrl = ref.watch(serverUrlProvider);
final String? id = file?.id ?? fileId;
final fallback =
Icon(
fallbackIcon ?? Symbols.account_circle,
size: radius,
color:
fallbackColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
).center();
final fallback = Icon(
fallbackIcon ?? Symbols.account_circle,
size: radius,
color: fallbackColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
).center();
final image = id == null
? fallback
: DataSavingGate(
bypass: true,
placeholder: fallback,
content: () => UniversalImage(
uri: '$serverUrl/drive/files/$id',
fit: BoxFit.cover,
),
);
Widget content = Container(
width: radius * 2,
height: radius * 2,
color: Theme.of(context).colorScheme.primaryContainer,
child: decoration != null
? Stack(
fit: StackFit.expand,
children: [
image,
CustomPaint(
painter: _ProfileDecorationPainter(
text: decoration!.text,
color: decoration!.color,
textColor: decoration!.textColor ?? Colors.white,
),
),
],
)
: image,
);
return ClipRRect(
borderRadius:
borderRadius == null
? BorderRadius.all(Radius.circular(radius))
: BorderRadius.all(Radius.circular(borderRadius!)),
child: Container(
width: radius * 2,
height: radius * 2,
color: Theme.of(context).colorScheme.primaryContainer,
child:
id == null
? fallback
: DataSavingGate(
bypass: true,
placeholder: fallback,
content:
() => UniversalImage(
uri: '$serverUrl/drive/files/$id',
fit: BoxFit.cover,
),
),
),
borderRadius: borderRadius == null
? BorderRadius.all(Radius.circular(radius))
: BorderRadius.all(Radius.circular(borderRadius!)),
child: content,
);
}
}
@@ -716,32 +729,29 @@ class SplitAvatarWidget extends ConsumerWidget {
),
),
Expanded(
child:
filesId.length > 4
? Container(
color:
Theme.of(
child: filesId.length > 4
? Container(
color: Theme.of(
context,
).colorScheme.primaryContainer,
child: Center(
child: Text(
'+${filesId.length - 3}',
style: TextStyle(
fontSize: radius * 0.4,
color: Theme.of(
context,
).colorScheme.primaryContainer,
child: Center(
child: Text(
'+${filesId.length - 3}',
style: TextStyle(
fontSize: radius * 0.4,
color:
Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
).colorScheme.onPrimaryContainer,
),
),
)
: _buildQuadrant(
context,
filesId[3],
ref,
radius,
),
)
: _buildQuadrant(
context,
filesId[3],
ref,
radius,
),
),
],
),
@@ -765,14 +775,12 @@ class SplitAvatarWidget extends ConsumerWidget {
width: radius,
height: radius,
color: Theme.of(context).colorScheme.primaryContainer,
child:
Icon(
fallbackIcon,
size: radius * 0.6,
color:
fallbackColor ??
Theme.of(context).colorScheme.onPrimaryContainer,
).center(),
child: Icon(
fallbackIcon,
size: radius * 0.6,
color:
fallbackColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
).center(),
);
}
@@ -786,3 +794,106 @@ class SplitAvatarWidget extends ConsumerWidget {
);
}
}
class _ProfileDecorationPainter extends CustomPainter {
final String text;
final Color color;
final Color textColor;
_ProfileDecorationPainter({
required this.text,
required this.color,
required this.textColor,
});
@override
void paint(Canvas canvas, Size size) {
if (text.isEmpty) return;
final radius = size.width / 2;
final center = Offset(size.width / 2, size.height / 2);
final strokeWidth = radius * 0.4; // Increased thickness
final centerAngle = 3 * math.pi / 4;
final sweepAngle = math.pi / 1;
final startAngle = centerAngle - (sweepAngle / 2);
final arcRadius = radius - (strokeWidth / 2);
final rect = Rect.fromCircle(center: center, radius: arcRadius);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..shader = SweepGradient(
startAngle: startAngle,
endAngle: startAngle + sweepAngle,
colors: [color.withOpacity(0), color, color, color.withOpacity(0)],
stops: const [0.0, 0.25, 0.75, 1.0],
).createShader(rect);
canvas.drawArc(rect, startAngle, sweepAngle, false, paint);
_drawTextOnArc(canvas, center, arcRadius, text, centerAngle);
}
void _drawTextOnArc(
Canvas canvas,
Offset center,
double radius,
String text,
double centerAngle,
) {
final textStyle = TextStyle(
color: textColor,
fontSize: radius * 0.28,
fontWeight: FontWeight.bold,
);
double totalAngle = 0;
List<double> charAngles = [];
// Calculate total angle occupied by text
for (int i = 0; i < text.length; i++) {
final char = text[i];
final span = TextSpan(text: char, style: textStyle);
final tp = TextPainter(text: span, textDirection: ui.TextDirection.ltr);
tp.layout();
final charWidth = tp.width;
final angle = charWidth / radius;
charAngles.add(angle);
totalAngle += angle;
}
// Start from "Left" of the center (High angle)
// We want to traverse from centerAngle + total/2 to centerAngle - total/2
double currentAngle = centerAngle + (totalAngle / 2);
for (int i = 0; i < text.length; i++) {
final char = text[i];
final span = TextSpan(text: char, style: textStyle);
final tp = TextPainter(text: span, textDirection: ui.TextDirection.ltr);
tp.layout();
final charAngle = charAngles[i];
final midCharAngle = currentAngle - charAngle / 2;
final x = center.dx + radius * math.cos(midCharAngle);
final y = center.dy + radius * math.sin(midCharAngle);
canvas.save();
canvas.translate(x, y);
canvas.rotate(midCharAngle - math.pi / 2);
tp.paint(canvas, Offset(-tp.width / 2, -tp.height / 2));
canvas.restore();
currentAngle -= charAngle;
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'profile_decoration.freezed.dart';
@freezed
sealed class ProfileDecoration with _$ProfileDecoration {
const factory ProfileDecoration({
required String text,
required Color color,
Color? textColor,
}) = _ProfileDecoration;
}

View File

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