|
|
|
import 'package:doublefeel_flutter/app/modules/friends/views/select_friend_view.dart';
|
|
|
|
import 'package:doublefeel_flutter/app/modules/interact/controllers/interact_controller.dart';
|
|
|
|
import 'package:doublefeel_flutter/app/modules/friends/models/friend_health_data.dart';
|
|
|
|
import 'package:doublefeel_flutter/data/models/interaction/interaction_models.dart';
|
|
|
|
import 'package:doublefeel_flutter/data/models/user/user_models.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:get/get.dart';
|
|
|
|
|
|
...
|
...
|
@@ -8,169 +9,167 @@ class InteractView extends GetView<InteractController> { |
|
|
|
const InteractView({super.key});
|
|
|
|
|
|
|
|
static const _purple = Color(0xff896cdc);
|
|
|
|
static const _muted = Color(0xff908b91);
|
|
|
|
static const _brandText = Color(0xff845eee);
|
|
|
|
static const _muted = Color(0xff78787d);
|
|
|
|
static const _placeholder = Color(0xffded3ff);
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Scaffold(
|
|
|
|
body: Stack(
|
|
|
|
fit: StackFit.expand,
|
|
|
|
children: [
|
|
|
|
Image.asset('assets/images/interact/page_bg.png', fit: BoxFit.cover),
|
|
|
|
SafeArea(
|
|
|
|
child: Obx(
|
|
|
|
() => Column(
|
|
|
|
children: [
|
|
|
|
_appBar(),
|
|
|
|
Expanded(
|
|
|
|
child: RefreshIndicator(
|
|
|
|
onRefresh: controller.loadRecords,
|
|
|
|
child: ListView(
|
|
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
|
|
padding: const EdgeInsets.only(bottom: 28),
|
|
|
|
children: [
|
|
|
|
_marquee(),
|
|
|
|
_characterArea(),
|
|
|
|
const SizedBox(height: 34),
|
|
|
|
_actionButtons(context),
|
|
|
|
const SizedBox(height: 28),
|
|
|
|
_records(),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
backgroundColor: const Color(0xfff5f2ff),
|
|
|
|
body: DecoratedBox(
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
gradient: LinearGradient(
|
|
|
|
colors: [Color(0xffc5b0ff), Color(0xfff5f2ff)],
|
|
|
|
begin: Alignment.topCenter,
|
|
|
|
end: Alignment(0, -.25),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
child: SafeArea(
|
|
|
|
child: Obx(() {
|
|
|
|
final hasInteractions = controller.records.isNotEmpty;
|
|
|
|
return Column(
|
|
|
|
children: [
|
|
|
|
_appBar(context),
|
|
|
|
Expanded(
|
|
|
|
child: RefreshIndicator(
|
|
|
|
onRefresh: controller.refreshPage,
|
|
|
|
child: ListView(
|
|
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
|
|
padding: const EdgeInsets.only(bottom: 28),
|
|
|
|
children: [
|
|
|
|
_interactionHero(hasInteractions),
|
|
|
|
_actionButtons(context),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
_records(),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Obx(() {
|
|
|
|
final asset = controller.actionMaskAsset();
|
|
|
|
if (asset == null) return const SizedBox.shrink();
|
|
|
|
return IgnorePointer(
|
|
|
|
child: Align(
|
|
|
|
alignment: const Alignment(0, -.5),
|
|
|
|
child:
|
|
|
|
Image.asset(asset, width: Get.width, fit: BoxFit.fitWidth),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _appBar() {
|
|
|
|
Widget _appBar(BuildContext context) {
|
|
|
|
return SizedBox(
|
|
|
|
height: 48,
|
|
|
|
height: 44,
|
|
|
|
child: Row(
|
|
|
|
children: [
|
|
|
|
IconButton(
|
|
|
|
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black),
|
|
|
|
icon: const Icon(
|
|
|
|
Icons.arrow_back_ios_new,
|
|
|
|
color: Color(0xff0f0f11),
|
|
|
|
size: 20,
|
|
|
|
),
|
|
|
|
onPressed: Get.back,
|
|
|
|
),
|
|
|
|
const Spacer(),
|
|
|
|
Expanded(
|
|
|
|
child: Text(
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: const TextStyle(
|
|
|
|
color: Color(0xff0f0f11),
|
|
|
|
fontSize: 16,
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
),
|
|
|
|
'互动',
|
|
|
|
),
|
|
|
|
),
|
|
|
|
IconButton(
|
|
|
|
tooltip: '刷新互动记录',
|
|
|
|
icon: const Icon(Icons.refresh, color: Colors.black87),
|
|
|
|
onPressed: controller.loadRecords,
|
|
|
|
tooltip: '切换好友',
|
|
|
|
icon: const Icon(
|
|
|
|
Icons.swap_horiz_rounded,
|
|
|
|
color: Color(0xff0f0f11),
|
|
|
|
size: 23,
|
|
|
|
),
|
|
|
|
onPressed: _changeFriend,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _marquee() {
|
|
|
|
final records = controller.records.take(3).toList(growable: false);
|
|
|
|
if (records.isEmpty) return const SizedBox(height: 72);
|
|
|
|
Widget _interactionHero(bool hasInteractions) {
|
|
|
|
// The 71px difference matches the Figma's extra interaction bubbles.
|
|
|
|
final characterTop = hasInteractions ? 127.0 : 56.0;
|
|
|
|
return SizedBox(
|
|
|
|
height: 72,
|
|
|
|
child: ListView.separated(
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
|
|
scrollDirection: Axis.horizontal,
|
|
|
|
itemCount: records.length,
|
|
|
|
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
|
|
|
itemBuilder: (_, index) {
|
|
|
|
final record = records[index];
|
|
|
|
final isMe = record.userId == controller.me?.id;
|
|
|
|
return Container(
|
|
|
|
constraints: const BoxConstraints(maxWidth: 210),
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
image: const DecorationImage(
|
|
|
|
image: AssetImage('assets/images/interact/page_bubble_bg.png'),
|
|
|
|
fit: BoxFit.fill,
|
|
|
|
height: hasInteractions ? 299 : 228,
|
|
|
|
child: Stack(
|
|
|
|
clipBehavior: Clip.none,
|
|
|
|
children: [
|
|
|
|
if (hasInteractions)
|
|
|
|
Positioned.fill(
|
|
|
|
child: _FloatingInteractionBubbles(
|
|
|
|
records: controller.records.take(3).toList(growable: false),
|
|
|
|
recordText: controller.recordText,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
top: characterTop,
|
|
|
|
left: 28,
|
|
|
|
right: 28,
|
|
|
|
child: Row(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
_avatar(isMe ? controller.me : controller.partner, size: 28),
|
|
|
|
const SizedBox(width: 7),
|
|
|
|
Flexible(
|
|
|
|
child: Text(
|
|
|
|
controller.recordText(record),
|
|
|
|
maxLines: 1,
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
style: const TextStyle(color: Colors.white, fontSize: 13),
|
|
|
|
Expanded(
|
|
|
|
child: _personPreview(
|
|
|
|
controller.friendLabel,
|
|
|
|
'好友',
|
|
|
|
controller.friendAvatarUrl,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(width: 28),
|
|
|
|
Expanded(
|
|
|
|
child: _personPreview(
|
|
|
|
controller.selfName,
|
|
|
|
'我',
|
|
|
|
controller.selfAvatarUrl,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _characterArea() {
|
|
|
|
final actionAsset = controller.partnerActionAsset();
|
|
|
|
return Padding(
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
|
|
child: Row(
|
|
|
|
children: [
|
|
|
|
Expanded(child: _person(me: true)),
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
Expanded(
|
|
|
|
child: controller.isPaired
|
|
|
|
? _person(me: false, actionAsset: actionAsset)
|
|
|
|
: _unpairedPerson(),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _person({required bool me, String? actionAsset}) {
|
|
|
|
final user = me ? controller.me : controller.partner;
|
|
|
|
Widget _personPreview(String name, String role, String? avatarUrl) {
|
|
|
|
return Column(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
SizedBox(
|
|
|
|
height: 132,
|
|
|
|
child: actionAsset == null
|
|
|
|
? _avatar(user,
|
|
|
|
size: 108,
|
|
|
|
borderColor: me ? _purple : const Color(0xfffff45b))
|
|
|
|
: Image.asset(actionAsset, fit: BoxFit.contain),
|
|
|
|
_profileImage(avatarUrl, size: 96, radius: 48),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
Container(
|
|
|
|
width: 78,
|
|
|
|
height: 2,
|
|
|
|
color: Colors.white.withValues(alpha: .8),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
const SizedBox(height: 14),
|
|
|
|
Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
children: [
|
|
|
|
if (me)
|
|
|
|
Container(
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
|
|
|
decoration:
|
|
|
|
const BoxDecoration(color: _purple, shape: BoxShape.circle),
|
|
|
|
child: const Text('我',
|
|
|
|
style: TextStyle(color: Colors.white, fontSize: 11)),
|
|
|
|
),
|
|
|
|
if (me) const SizedBox(width: 5),
|
|
|
|
_profileImage(avatarUrl, size: 36, radius: 18),
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
Flexible(
|
|
|
|
child: Text(
|
|
|
|
user?.nickname ?? '-',
|
|
|
|
child: Text.rich(
|
|
|
|
TextSpan(
|
|
|
|
text: name,
|
|
|
|
style: const TextStyle(
|
|
|
|
color: Color(0xff0f0f11),
|
|
|
|
fontSize: 14,
|
|
|
|
),
|
|
|
|
children: [
|
|
|
|
TextSpan(
|
|
|
|
text: '($role)',
|
|
|
|
style: const TextStyle(color: _muted),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
maxLines: 1,
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
style:
|
|
|
|
const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
...
|
...
|
@@ -179,39 +178,35 @@ class InteractView extends GetView<InteractController> { |
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _unpairedPerson() {
|
|
|
|
return Column(
|
|
|
|
children: [
|
|
|
|
Image.asset('assets/images/interact/unpaired_avatar.png', width: 58),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
const Text('暂未绑定Feel搭子',
|
|
|
|
style: TextStyle(color: Color(0xffff2c20), fontSize: 12)),
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
TextButton.icon(
|
|
|
|
onPressed: () => Get.snackbar('互动', '请先前往搭子页面完成绑定'),
|
|
|
|
icon: const Text('去绑定'),
|
|
|
|
label: const Icon(Icons.chevron_right, size: 18),
|
|
|
|
style: TextButton.styleFrom(
|
|
|
|
foregroundColor: Colors.white,
|
|
|
|
backgroundColor: _purple,
|
|
|
|
shape: const StadiumBorder(),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _actionButtons(BuildContext context) {
|
|
|
|
return Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
|
|
children: [
|
|
|
|
_actionButton(context, InteractAction.miss, width: 88, height: 52),
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
_actionButton(context, InteractAction.stick, width: 112, height: 72),
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
_actionButton(context, InteractAction.punch, width: 88, height: 52),
|
|
|
|
],
|
|
|
|
return SizedBox(
|
|
|
|
height: 112,
|
|
|
|
child: Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
_actionButton(
|
|
|
|
context,
|
|
|
|
InteractAction.miss,
|
|
|
|
width: 90,
|
|
|
|
elevated: false,
|
|
|
|
),
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
_actionButton(
|
|
|
|
context,
|
|
|
|
InteractAction.stick,
|
|
|
|
width: 116,
|
|
|
|
elevated: true,
|
|
|
|
),
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
_actionButton(
|
|
|
|
context,
|
|
|
|
InteractAction.punch,
|
|
|
|
width: 90,
|
|
|
|
elevated: false,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
...
|
...
|
@@ -219,7 +214,7 @@ class InteractView extends GetView<InteractController> { |
|
|
|
BuildContext context,
|
|
|
|
InteractAction action, {
|
|
|
|
required double width,
|
|
|
|
required double height,
|
|
|
|
required bool elevated,
|
|
|
|
}) {
|
|
|
|
final enabled =
|
|
|
|
controller.isPaired && (!action.needsVip || controller.isVip);
|
|
...
|
...
|
@@ -228,41 +223,59 @@ class InteractView extends GetView<InteractController> { |
|
|
|
onLongPress: () => _showActionSheet(context),
|
|
|
|
child: SizedBox(
|
|
|
|
width: width,
|
|
|
|
height: height + 18,
|
|
|
|
child: ElevatedButton(
|
|
|
|
onPressed: sending ? null : () => controller.sendAction(action),
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
backgroundColor: _purple.withValues(alpha: enabled ? 1 : .4),
|
|
|
|
disabledBackgroundColor: _purple.withValues(alpha: .4),
|
|
|
|
elevation: 5,
|
|
|
|
shadowColor: _purple.withValues(alpha: .5),
|
|
|
|
shape:
|
|
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
|
|
|
),
|
|
|
|
child: sending
|
|
|
|
? const SizedBox(
|
|
|
|
width: 22,
|
|
|
|
height: 22,
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
color: Colors.white, strokeWidth: 2))
|
|
|
|
: Column(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
children: [
|
|
|
|
Image.asset(
|
|
|
|
enabled
|
|
|
|
? action.iconAsset
|
|
|
|
: 'assets/images/interact/icon_locked.png',
|
|
|
|
width: action == InteractAction.stick ? 48 : 40,
|
|
|
|
height: action == InteractAction.stick ? 48 : 40,
|
|
|
|
),
|
|
|
|
Image.asset(
|
|
|
|
enabled ? action.titleAsset : action.lockedTitleAsset,
|
|
|
|
height: 20,
|
|
|
|
fit: BoxFit.contain,
|
|
|
|
height: 112,
|
|
|
|
child: Stack(
|
|
|
|
clipBehavior: Clip.none,
|
|
|
|
alignment: Alignment.topCenter,
|
|
|
|
children: [
|
|
|
|
Positioned(
|
|
|
|
top: elevated ? 34 : 49,
|
|
|
|
child: Opacity(
|
|
|
|
opacity: enabled ? 1 : .4,
|
|
|
|
child: Material(
|
|
|
|
color: _purple,
|
|
|
|
borderRadius: BorderRadius.circular(elevated ? 24 : 16),
|
|
|
|
child: InkWell(
|
|
|
|
onTap: sending ? null : () => controller.sendAction(action),
|
|
|
|
borderRadius: BorderRadius.circular(elevated ? 24 : 16),
|
|
|
|
child: SizedBox(
|
|
|
|
width: width,
|
|
|
|
height: elevated ? 78 : 56,
|
|
|
|
child: Center(
|
|
|
|
child: sending
|
|
|
|
? const SizedBox(
|
|
|
|
width: 22,
|
|
|
|
height: 22,
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
color: Colors.white,
|
|
|
|
strokeWidth: 2,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
: Text(
|
|
|
|
action.title,
|
|
|
|
style: TextStyle(
|
|
|
|
color: Colors.white,
|
|
|
|
fontSize: elevated ? 20 : 14,
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
top: elevated ? 0 : 19,
|
|
|
|
child: _colorPlaceholder(
|
|
|
|
elevated ? 68 : 52,
|
|
|
|
color: enabled
|
|
|
|
? const Color(0xffc5b0ff)
|
|
|
|
: const Color(0xffd5cfe3),
|
|
|
|
radius: elevated ? 22 : 18,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
...
|
...
|
@@ -271,25 +284,25 @@ class InteractView extends GetView<InteractController> { |
|
|
|
Widget _records() {
|
|
|
|
final records = controller.records.take(10).toList(growable: false);
|
|
|
|
return Container(
|
|
|
|
margin: const EdgeInsets.only(top: 2),
|
|
|
|
padding: const EdgeInsets.fromLTRB(24, 20, 24, 42),
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
gradient: LinearGradient(
|
|
|
|
colors: [Color(0xffeae1ff), Color(0x00fcf4ff)],
|
|
|
|
begin: Alignment.topCenter,
|
|
|
|
end: Alignment.bottomCenter,
|
|
|
|
),
|
|
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
|
|
constraints: const BoxConstraints(minHeight: 208),
|
|
|
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
|
padding: const EdgeInsets.fromLTRB(16, 17, 16, 18),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Colors.white.withValues(alpha: .76),
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
),
|
|
|
|
child: Column(
|
|
|
|
children: [
|
|
|
|
Row(
|
|
|
|
children: [
|
|
|
|
const Text('互动记录',
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 17,
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
color: Color(0xff2c2020))),
|
|
|
|
const Text(
|
|
|
|
'互动记录',
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 16,
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
color: Color(0xff0f0f11),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const Spacer(),
|
|
|
|
Text('仅保留10条', style: TextStyle(fontSize: 12, color: _muted)),
|
|
|
|
],
|
|
...
|
...
|
@@ -297,12 +310,16 @@ class InteractView extends GetView<InteractController> { |
|
|
|
const SizedBox(height: 15),
|
|
|
|
if (controller.isLoading.value && records.isEmpty)
|
|
|
|
const Padding(
|
|
|
|
padding: EdgeInsets.all(40), child: CircularProgressIndicator())
|
|
|
|
padding: EdgeInsets.all(40),
|
|
|
|
child: CircularProgressIndicator(),
|
|
|
|
)
|
|
|
|
else if (records.isEmpty)
|
|
|
|
const Padding(
|
|
|
|
padding: EdgeInsets.symmetric(vertical: 76),
|
|
|
|
child: Text('暂时还没有记录哦~',
|
|
|
|
style: TextStyle(color: _muted, fontSize: 14)),
|
|
|
|
child: Text(
|
|
|
|
'暂无互动记录',
|
|
|
|
style: TextStyle(color: _muted, fontSize: 14),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
else
|
|
|
|
...records.map(_recordItem),
|
|
...
|
...
|
@@ -312,47 +329,99 @@ class InteractView extends GetView<InteractController> { |
|
|
|
}
|
|
|
|
|
|
|
|
Widget _recordItem(InteractionRecord record) {
|
|
|
|
return Container(
|
|
|
|
width: double.infinity,
|
|
|
|
margin: const EdgeInsets.only(bottom: 7),
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Colors.white.withValues(alpha: .48),
|
|
|
|
borderRadius: BorderRadius.circular(7)),
|
|
|
|
return Padding(
|
|
|
|
padding: const EdgeInsets.only(bottom: 13),
|
|
|
|
child: Row(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
const Icon(Icons.circle_outlined, size: 10, color: _purple),
|
|
|
|
Container(
|
|
|
|
width: 8,
|
|
|
|
height: 8,
|
|
|
|
margin: const EdgeInsets.only(top: 5),
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
color: _brandText,
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
Expanded(
|
|
|
|
child: Text(controller.recordText(record),
|
|
|
|
style: const TextStyle(fontSize: 14))),
|
|
|
|
Text(controller.recordTime(record),
|
|
|
|
style: const TextStyle(fontSize: 12, color: _muted)),
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Text(
|
|
|
|
controller.recordText(record),
|
|
|
|
style: const TextStyle(
|
|
|
|
fontSize: 14,
|
|
|
|
color: Color(0xff0f0f11),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 3),
|
|
|
|
Text(
|
|
|
|
controller.recordTime(record),
|
|
|
|
style: const TextStyle(
|
|
|
|
fontSize: 12,
|
|
|
|
color: Color(0xffb0b0b6),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _avatar(UserInfoResponse? user,
|
|
|
|
{required double size, Color? borderColor}) {
|
|
|
|
final avatar = user?.avatar;
|
|
|
|
Widget _colorPlaceholder(
|
|
|
|
double size, {
|
|
|
|
required Color color,
|
|
|
|
required double radius,
|
|
|
|
}) {
|
|
|
|
return Container(
|
|
|
|
width: size,
|
|
|
|
height: size,
|
|
|
|
clipBehavior: Clip.antiAlias,
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
border: Border.all(color: borderColor ?? _purple, width: 2),
|
|
|
|
color: Colors.white),
|
|
|
|
child: avatar == null || avatar.isEmpty
|
|
|
|
? Icon(Icons.person, size: size * .55, color: _muted)
|
|
|
|
: Image.network(avatar,
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
errorBuilder: (_, __, ___) =>
|
|
|
|
Icon(Icons.person, size: size * .55, color: _muted)),
|
|
|
|
color: color,
|
|
|
|
borderRadius: BorderRadius.circular(radius),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _profileImage(
|
|
|
|
String? avatarUrl, {
|
|
|
|
required double size,
|
|
|
|
required double radius,
|
|
|
|
}) {
|
|
|
|
if (avatarUrl == null || avatarUrl.trim().isEmpty) {
|
|
|
|
return _colorPlaceholder(size, color: _placeholder, radius: radius);
|
|
|
|
}
|
|
|
|
return ClipRRect(
|
|
|
|
borderRadius: BorderRadius.circular(radius),
|
|
|
|
child: Image.network(
|
|
|
|
avatarUrl,
|
|
|
|
width: size,
|
|
|
|
height: size,
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
errorBuilder: (_, __, ___) =>
|
|
|
|
_colorPlaceholder(size, color: _placeholder, radius: radius),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Future<void> _changeFriend() async {
|
|
|
|
final selectedFriend = await Get.bottomSheet<FriendHealthData>(
|
|
|
|
SizedBox(height: Get.height * 0.9, child: const SelectFriendView()),
|
|
|
|
ignoreSafeArea: false,
|
|
|
|
isScrollControlled: true,
|
|
|
|
backgroundColor: Colors.transparent,
|
|
|
|
barrierColor: const Color(0xB3000000),
|
|
|
|
);
|
|
|
|
if (selectedFriend == null) return;
|
|
|
|
if (selectedFriend.userId == controller.selectedFriend.value?.userId) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
await controller.selectFriend(selectedFriend);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _showActionSheet(BuildContext context) {
|
|
|
|
Get.bottomSheet(
|
|
|
|
InteractActionBottomSheet(controller: controller),
|
|
...
|
...
|
@@ -362,6 +431,111 @@ class InteractView extends GetView<InteractController> { |
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// A deliberately sparse, looping barrage. Each message begins offscreen on
|
|
|
|
/// the right and re-enters after it has fully left the left edge.
|
|
|
|
class _FloatingInteractionBubbles extends StatefulWidget {
|
|
|
|
const _FloatingInteractionBubbles({
|
|
|
|
required this.records,
|
|
|
|
required this.recordText,
|
|
|
|
});
|
|
|
|
|
|
|
|
final List<InteractionRecord> records;
|
|
|
|
final String Function(InteractionRecord record) recordText;
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<_FloatingInteractionBubbles> createState() =>
|
|
|
|
_FloatingInteractionBubblesState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _FloatingInteractionBubblesState
|
|
|
|
extends State<_FloatingInteractionBubbles>
|
|
|
|
with SingleTickerProviderStateMixin {
|
|
|
|
late final AnimationController _animationController = AnimationController(
|
|
|
|
vsync: this,
|
|
|
|
duration: const Duration(seconds: 11),
|
|
|
|
)..repeat();
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
_animationController.dispose();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
// The staggered vertical tracks and phases prevent this from looking like
|
|
|
|
// a single marquee while still keeping it a calm, readable barrage.
|
|
|
|
const tracks = <double>[12, 53, 74];
|
|
|
|
const phases = <double>[.06, .47, .79];
|
|
|
|
return LayoutBuilder(
|
|
|
|
builder: (context, constraints) => AnimatedBuilder(
|
|
|
|
animation: _animationController,
|
|
|
|
builder: (context, _) => Stack(
|
|
|
|
clipBehavior: Clip.hardEdge,
|
|
|
|
children: List.generate(widget.records.length, (index) {
|
|
|
|
final progress = (_animationController.value + phases[index]) % 1;
|
|
|
|
// 170 covers the maximum bubble width and its exit gap.
|
|
|
|
final left =
|
|
|
|
constraints.maxWidth - progress * (constraints.maxWidth + 170);
|
|
|
|
return Positioned(
|
|
|
|
top: tracks[index],
|
|
|
|
left: left,
|
|
|
|
child: _FloatingInteractionBubble(
|
|
|
|
record: widget.records[index],
|
|
|
|
recordText: widget.recordText,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _FloatingInteractionBubble extends StatelessWidget {
|
|
|
|
const _FloatingInteractionBubble({
|
|
|
|
required this.record,
|
|
|
|
required this.recordText,
|
|
|
|
});
|
|
|
|
|
|
|
|
final InteractionRecord record;
|
|
|
|
final String Function(InteractionRecord record) recordText;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Container(
|
|
|
|
constraints: const BoxConstraints(maxWidth: 142),
|
|
|
|
padding: const EdgeInsets.fromLTRB(6, 5, 10, 5),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: const Color(0xffa084ef),
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
),
|
|
|
|
child: Row(
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
children: [
|
|
|
|
Container(
|
|
|
|
width: 28,
|
|
|
|
height: 28,
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: const Color(0xffc5b0ff),
|
|
|
|
borderRadius: BorderRadius.circular(9),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(width: 5),
|
|
|
|
Flexible(
|
|
|
|
child: Text(
|
|
|
|
recordText(record),
|
|
|
|
maxLines: 1,
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class InteractActionBottomSheet extends StatelessWidget {
|
|
|
|
const InteractActionBottomSheet({required this.controller, super.key});
|
|
|
|
|
|
...
|
...
|
@@ -375,41 +549,49 @@ class InteractActionBottomSheet extends StatelessWidget { |
|
|
|
height: 331,
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
image: DecorationImage(
|
|
|
|
image: AssetImage('assets/images/interact/bottom_sheet_bg.png'),
|
|
|
|
fit: BoxFit.fill),
|
|
|
|
image: AssetImage('assets/images/interact/bottom_sheet_bg.png'),
|
|
|
|
fit: BoxFit.fill,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
child: Stack(
|
|
|
|
children: [
|
|
|
|
Align(
|
|
|
|
alignment: const Alignment(.2, -.98),
|
|
|
|
child: Image.asset(
|
|
|
|
'assets/images/interact/bottom_sheet_label.png',
|
|
|
|
width: 126),
|
|
|
|
'assets/images/interact/bottom_sheet_label.png',
|
|
|
|
width: 126,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Column(
|
|
|
|
children: [
|
|
|
|
const SizedBox(height: 18),
|
|
|
|
_sheetAvatar(),
|
|
|
|
const SizedBox(height: 13),
|
|
|
|
const Text('给搭子送去一份互动',
|
|
|
|
style: TextStyle(color: Color(0xff908b91), fontSize: 14)),
|
|
|
|
const Text(
|
|
|
|
'给搭子送去一份互动',
|
|
|
|
style: TextStyle(color: Color(0xff908b91), fontSize: 14),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 3),
|
|
|
|
const Text('想念要说出来',
|
|
|
|
style: TextStyle(
|
|
|
|
color: Color(0xff896cdc),
|
|
|
|
fontSize: 24,
|
|
|
|
fontWeight: FontWeight.w700)),
|
|
|
|
const Text(
|
|
|
|
'想念要说出来',
|
|
|
|
style: TextStyle(
|
|
|
|
color: Color(0xff896cdc),
|
|
|
|
fontSize: 24,
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
Obx(
|
|
|
|
() => Row(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
|
|
children: InteractAction.values
|
|
|
|
.map((action) => Padding(
|
|
|
|
padding:
|
|
|
|
const EdgeInsets.symmetric(horizontal: 6),
|
|
|
|
child: _sheetButton(action),
|
|
|
|
))
|
|
|
|
.map(
|
|
|
|
(action) => Padding(
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
|
|
child: _sheetButton(action),
|
|
|
|
),
|
|
|
|
)
|
|
|
|
.toList(growable: false),
|
|
|
|
),
|
|
|
|
),
|
|
...
|
...
|
@@ -428,8 +610,9 @@ class InteractActionBottomSheet extends StatelessWidget { |
|
|
|
height: 56,
|
|
|
|
clipBehavior: Clip.antiAlias,
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
border: Border.all(color: const Color(0xff896cdc), width: 2)),
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
border: Border.all(color: const Color(0xff896cdc), width: 2),
|
|
|
|
),
|
|
|
|
child: avatar == null || avatar.isEmpty
|
|
|
|
? const Icon(Icons.person, color: Color(0xff908b91))
|
|
|
|
: Image.network(avatar, fit: BoxFit.cover),
|
|
...
|
...
|
@@ -455,25 +638,31 @@ class InteractActionBottomSheet extends StatelessWidget { |
|
|
|
},
|
|
|
|
style: ElevatedButton.styleFrom(
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
backgroundColor:
|
|
|
|
const Color(0xff896cdc).withValues(alpha: enabled ? 1 : .4),
|
|
|
|
shape:
|
|
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
|
|
|
backgroundColor: const Color(
|
|
|
|
0xff896cdc,
|
|
|
|
).withValues(alpha: enabled ? 1 : .4),
|
|
|
|
shape: RoundedRectangleBorder(
|
|
|
|
borderRadius: BorderRadius.circular(28),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
child: sending
|
|
|
|
? const CircularProgressIndicator(
|
|
|
|
color: Colors.white, strokeWidth: 2)
|
|
|
|
color: Colors.white,
|
|
|
|
strokeWidth: 2,
|
|
|
|
)
|
|
|
|
: Column(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
children: [
|
|
|
|
Image.asset(
|
|
|
|
enabled
|
|
|
|
? action.iconAsset
|
|
|
|
: 'assets/images/interact/icon_locked.png',
|
|
|
|
height: 38),
|
|
|
|
enabled
|
|
|
|
? action.iconAsset
|
|
|
|
: 'assets/images/interact/icon_locked.png',
|
|
|
|
height: 38,
|
|
|
|
),
|
|
|
|
Image.asset(
|
|
|
|
enabled ? action.titleAsset : action.lockedTitleAsset,
|
|
|
|
height: 18),
|
|
|
|
enabled ? action.titleAsset : action.lockedTitleAsset,
|
|
|
|
height: 18,
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
...
|
...
|
|