ai voice chat edit

This commit is contained in:
Mohamad Mahdi Jebeli 2026-07-18 15:19:21 +03:30
parent 7ca14108b9
commit 008baa71f9
13 changed files with 2539 additions and 246 deletions

4
.gitignore vendored
View File

@ -1,6 +1,10 @@
android/app/.cxx android/app/.cxx
android/app/.cxx/Debug android/app/.cxx/Debug
# Graphify generated output
/graphify-out/
# Miscellaneous # Miscellaneous
*.class *.class
*.log *.log

View File

@ -3,10 +3,12 @@ package com.didvan.didvanapp
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.AudioManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import android.view.KeyEvent
import androidx.annotation.NonNull import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@ -17,10 +19,36 @@ import java.io.FileInputStream
class MainActivity: FlutterActivity() { class MainActivity: FlutterActivity() {
private val CHANNEL = "com.didvan.shareFile" private val CHANNEL = "com.didvan.shareFile"
private val AUDIO_EVENTS_CHANNEL = "com.didvan.didvanapp/audio_events"
private var audioEventsChannel: MethodChannel? = null
private var outputProbePreviousVolume: Int? = null
@Override @Override
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
audioEventsChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
AUDIO_EVENTS_CHANNEL
)
audioEventsChannel?.setMethodCallHandler { call, result ->
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
when (call.method) {
"beginOutputProbe" -> {
if (outputProbePreviousVolume == null) {
outputProbePreviousVolume =
audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
}
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, 0, 0)
result.success(null)
}
"endOutputProbe" -> {
restoreOutputProbeVolume(audioManager)
result.success(null)
}
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
@ -45,6 +73,42 @@ class MainActivity: FlutterActivity() {
} }
} }
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (
event.keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN ||
event.keyCode == KeyEvent.KEYCODE_VOLUME_MUTE
) {
// Do not consume the key: Android must still change the volume.
// One physical press produces DOWN, repeat and UP events. Notify
// Dart only once so a held key cannot continuously restart its
// acoustic-path guard.
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
audioEventsChannel?.invokeMethod(
"volumeChanged",
mapOf(
"keyCode" to event.keyCode
)
)
}
}
return super.dispatchKeyEvent(event)
}
override fun onDestroy() {
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
restoreOutputProbeVolume(audioManager)
audioEventsChannel?.setMethodCallHandler(null)
audioEventsChannel = null
super.onDestroy()
}
private fun restoreOutputProbeVolume(audioManager: AudioManager) {
val previousVolume = outputProbePreviousVolume ?: return
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, previousVolume, 0)
outputProbePreviousVolume = null
}
private fun copyFileToDownloads(filePath: String, fileName: String): Boolean { private fun copyFileToDownloads(filePath: String, fileName: String): Boolean {
return try { return try {
val context: Context = applicationContext val context: Context = applicationContext

View File

@ -1,16 +1,3 @@
buildscript {
ext.kotlin_version = '1.9.0' // (ممکن است ورژن کاتلین شما متفاوت باشد، به آن دست نزنید)
repositories {
google() // حتماً خط اول باشد
mavenCentral() // حتماً خط دوم باشد
}
dependencies {
classpath 'com.android.tools.build:gradle:7.3.0' // (ورژن گریدل شما)
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects { allprojects {
repositories { repositories {
google() // حتماً خط اول باشد google() // حتماً خط اول باشد
@ -42,4 +29,4 @@ subprojects {
force 'androidx.activity:activity-ktx:1.9.3' force 'androidx.activity:activity-ktx:1.9.3'
} }
} }
} }

296
lib/shaders/ai_orb_3d.frag Normal file
View File

@ -0,0 +1,296 @@
#include <flutter/runtime_effect.glsl>
uniform vec2 uResolution;
uniform float uTime;
uniform float uActivity;
uniform vec3 uAccent;
uniform float uTransient;
uniform float uTexture;
uniform float uMotionPhase;
out vec4 fragColor;
const float PI = 3.14159265359;
float saturate(float value) {
return clamp(value, 0.0, 1.0);
}
float hash21(vec2 point) {
point = fract(point * vec2(123.34, 456.21));
point += dot(point, point + 45.32);
return fract(point.x * point.y);
}
float softNoise(vec2 point) {
vec2 cell = floor(point);
vec2 local = fract(point);
local = local * local * (3.0 - 2.0 * local);
return mix(
mix(hash21(cell), hash21(cell + vec2(1.0, 0.0)), local.x),
mix(hash21(cell + vec2(0.0, 1.0)), hash21(cell + 1.0), local.x),
local.y
);
}
float softBox(vec2 point, vec2 halfSize, float feather) {
vec2 distance = abs(point) - halfSize;
return 1.0 - smoothstep(
0.0,
feather,
max(distance.x, distance.y)
);
}
float directionalLobe(vec2 radial, vec2 direction, float sharpness) {
return pow(saturate(dot(radial, normalize(direction))), sharpness);
}
vec3 studioEnvironment(vec3 direction) {
float skyMix = smoothstep(-0.55, 0.72, direction.y);
vec3 floorColor = vec3(0.012, 0.016, 0.028);
vec3 skyColor = vec3(0.36, 0.46, 0.68);
vec3 environmentColor = mix(floorColor, skyColor, skyMix);
float horizonStrip = pow(
saturate(1.0 - abs(direction.y + 0.09) * 4.8),
10.0
);
environmentColor += vec3(0.68, 0.78, 1.0) * horizonStrip * 0.42;
float keyPanel = pow(
saturate(dot(direction, normalize(vec3(-0.69, -0.28, 0.67)))),
24.0
);
environmentColor += vec3(0.93, 0.96, 1.0) * keyPanel * 0.72;
float rimPanel = pow(
saturate(dot(direction, normalize(vec3(0.76, 0.04, 0.65)))),
34.0
);
environmentColor += vec3(0.34, 0.52, 1.0) * rimPanel * 0.48;
float warmPanel = pow(
saturate(dot(direction, normalize(vec3(0.42, 0.58, 0.69)))),
42.0
);
environmentColor += vec3(0.76, 0.49, 0.31) * warmPanel * 0.16;
return environmentColor;
}
void main() {
vec2 fragCoord = FlutterFragCoord().xy;
vec2 centered = fragCoord - uResolution * 0.5;
vec2 uv = centered / min(uResolution.x, uResolution.y);
float activity = saturate(uActivity);
float transientEnergy = saturate(uTransient);
float textureEnergy = saturate(uTexture);
float timePhase = uTime * PI * 2.0;
float voiceEnergy = smoothstep(0.045, 0.88, activity);
float radius = 0.326 + voiceEnergy * 0.0015;
float distanceToCenter = length(uv);
float safeDistance = max(distanceToCenter, 0.0001);
vec2 radial = uv / safeDistance;
float distanceFromSurface = distanceToCenter - radius;
// Reflections around the silhouette are directional rather than a uniform
// neon ring. They read as large studio lights caught by a glass surface.
float perimeterBand = exp(-abs(distanceFromSurface) * 72.0);
float upperLeftArc = directionalLobe(
radial,
vec2(-0.72, -0.69),
8.0
);
float rightArc = directionalLobe(radial, vec2(0.96, -0.12), 13.0);
float lowerArc = directionalLobe(radial, vec2(0.28, 0.96), 18.0);
float arcBreath = 0.92 + 0.08 * sin(
uMotionPhase * 0.72 + textureEnergy * 1.8
);
vec3 perimeterReflection =
vec3(0.88, 0.93, 1.0) * upperLeftArc * 0.38 +
mix(uAccent, vec3(0.48, 0.68, 1.0), 0.48) * rightArc * 0.3 +
vec3(0.95, 0.58, 0.38) * lowerArc * 0.075;
perimeterReflection *= perimeterBand * arcBreath;
float halo = exp(-max(distanceFromSurface, 0.0) * 15.0);
halo *= 1.0 - smoothstep(radius, radius * 2.42, distanceToCenter);
vec3 haloColor = uAccent * halo * (
0.025 + voiceEnergy * 0.055 + transientEnergy * 0.025
);
// A soft contact shadow and a dim, compressed floor reflection ground the
// sphere in space. Voice changes the reflection, not its physical position.
vec2 shadowUv = vec2(
uv.x / (radius * 1.08),
(uv.y - radius * 1.12) / (radius * 0.18)
);
float contactShadow = exp(
-(shadowUv.x * shadowUv.x * 2.3 + shadowUv.y * shadowUv.y * 1.55)
);
float reflectionBreakup = 0.72 + 0.28 * sin(
shadowUv.x * 7.0 + uMotionPhase * 0.36
);
float floorReflection = contactShadow * reflectionBreakup *
(0.12 + voiceEnergy * 0.1);
vec3 outsideColor =
haloColor +
perimeterReflection +
uAccent * floorReflection * 0.16 +
vec3(0.018, 0.022, 0.036) * contactShadow * 0.62;
float outsideAlpha = saturate(
halo * 0.17 +
perimeterBand * (upperLeftArc * 0.42 + rightArc * 0.34) +
contactShadow * 0.34
);
if (distanceToCenter > radius) {
fragColor = vec4(outsideColor, outsideAlpha);
return;
}
float z = sqrt(max(radius * radius - dot(uv, uv), 0.0));
vec3 geometricNormal = normalize(vec3(uv / radius, z / radius));
// Microscopic normal variation gives the material thickness without
// visibly deforming the sphere. Speech texture subtly increases it.
vec2 noiseCoordinate =
geometricNormal.xy * 5.1 +
vec2(timePhase * 0.025, -timePhase * 0.018);
float noiseA = softNoise(noiseCoordinate);
float noiseB = softNoise(noiseCoordinate * 1.73 + vec2(2.7, -1.9));
vec2 microNormal = vec2(noiseA - 0.5, noiseB - 0.5) *
(0.0035 + textureEnergy * 0.009);
vec3 normal = normalize(vec3(
geometricNormal.xy + microNormal,
geometricNormal.z
));
vec3 viewDirection = vec3(0.0, 0.0, 1.0);
vec3 keyDirection = normalize(vec3(
-0.48 + sin(uMotionPhase * 0.31) * voiceEnergy * 0.012,
-0.63 + cos(uMotionPhase * 0.27) * voiceEnergy * 0.009,
0.88
));
vec3 fillDirection = normalize(vec3(0.76, 0.16, 0.54));
float diffuse = max(dot(normal, keyDirection), 0.0);
float fill = max(dot(normal, fillDirection), 0.0);
float ndv = saturate(dot(normal, viewDirection));
float fresnel = 0.035 + 0.965 * pow(1.0 - ndv, 5.0);
float thickness = (2.0 * z) / radius;
vec3 reflectedDirection = normalize(reflect(-viewDirection, normal));
vec3 reflection = studioEnvironment(reflectedDirection);
// Three close indices of refraction create restrained chromatic dispersion
// at the edge, a key cue for real optical glass.
vec3 refractedRed = studioEnvironment(normalize(
refract(-viewDirection, normal, 0.695)
));
vec3 refractedGreen = studioEnvironment(normalize(
refract(-viewDirection, normal, 0.715)
));
vec3 refractedBlue = studioEnvironment(normalize(
refract(-viewDirection, normal, 0.738)
));
vec3 refractedEnvironment = vec3(
refractedRed.r,
refractedGreen.g,
refractedBlue.b
);
vec3 absorption = vec3(0.19, 0.13, 0.075);
vec3 transmission = exp(-absorption * thickness);
vec3 deepTint = mix(vec3(0.008, 0.012, 0.024), uAccent * 0.12, 0.38);
vec3 color = deepTint * (0.2 + diffuse * 0.16 + fill * 0.04);
color += refractedEnvironment * transmission * (0.15 + ndv * 0.08);
color += reflection * (0.22 + fresnel * 1.04);
color += uAccent * fresnel * (0.035 + voiceEnergy * 0.065);
// Coherent rectangular reflections sell the object as a glossy 3D sphere.
float surfaceVisibility = smoothstep(0.12, 0.7, geometricNormal.z);
float keySoftbox = softBox(
normal.xy - vec2(-0.34, -0.3),
vec2(0.055, 0.235),
0.055
) * surfaceVisibility;
float topSoftbox = softBox(
normal.xy - vec2(0.05, -0.49),
vec2(0.27, 0.035),
0.06
) * surfaceVisibility;
float sideSoftbox = softBox(
normal.xy - vec2(0.56, 0.03),
vec2(0.026, 0.19),
0.048
) * surfaceVisibility;
color += vec3(0.91, 0.95, 1.0) * keySoftbox * 0.19;
color += vec3(0.72, 0.82, 1.0) * topSoftbox * 0.08;
color += mix(uAccent, vec3(0.5, 0.7, 1.0), 0.52) *
sideSoftbox * 0.13;
vec3 halfVector = normalize(keyDirection + viewDirection);
float sharpSpecular = pow(max(dot(normal, halfVector), 0.0), 180.0);
color += vec3(1.0, 0.98, 0.95) * sharpSpecular * 1.55;
vec3 broadHalf = normalize(vec3(-0.58, -0.4, 0.72) + viewDirection);
float broadSpecular = pow(max(dot(normal, broadHalf), 0.0), 25.0);
color += vec3(0.72, 0.82, 1.0) * broadSpecular * 0.1;
// Keep the optical center dark and clean. Speech travels through the glass
// as thin refracted sheets instead of an artificial glowing blob.
vec2 normalizedUv = uv / radius;
float internalDepth =
1.0 - smoothstep(0.12, 0.98, length(normalizedUv));
float cleanCenter = pow(internalDepth, 1.65);
color *= 1.0 - cleanCenter * 0.115;
float voicePulse = 0.82 + 0.18 * sin(
uMotionPhase * 1.23 + textureEnergy * 1.6
);
float primarySheetY =
normalizedUv.y +
normalizedUv.x * 0.11 +
0.13 -
sin(uMotionPhase * 0.54) * voiceEnergy * 0.055;
float primarySheet = exp(-primarySheetY * primarySheetY * 82.0) *
exp(-dot(normalizedUv, normalizedUv) * 1.15);
float secondarySheetY =
normalizedUv.y -
normalizedUv.x * 0.07 -
0.42 +
cos(uMotionPhase * 0.39) * voiceEnergy * 0.035;
float secondarySheet = exp(
-secondarySheetY * secondarySheetY * 115.0
) * exp(-dot(normalizedUv, normalizedUv) * 1.5);
color += mix(uAccent, vec3(0.66, 0.8, 1.0), 0.38) *
primarySheet * voiceEnergy * voicePulse * 0.052;
color += vec3(0.68, 0.8, 1.0) * secondarySheet *
voiceEnergy * 0.026;
float onsetFlash = pow(1.0 - ndv, 2.2) *
upperLeftArc * transientEnergy;
color += vec3(0.86, 0.92, 1.0) * onsetFlash * 0.18;
float causticPhase =
normalizedUv.x * 14.0 +
normalizedUv.y * 9.0 -
uMotionPhase * (0.52 + textureEnergy * 0.38) +
noiseA * 2.4;
float caustic = pow(saturate(0.5 + 0.5 * sin(causticPhase)), 9.0);
float causticBand = smoothstep(0.24, 0.52, length(normalizedUv)) *
(1.0 - smoothstep(0.78, 0.97, length(normalizedUv)));
caustic *= causticBand * voiceEnergy * (0.25 + textureEnergy * 0.75);
color += mix(uAccent, vec3(0.78, 0.88, 1.0), 0.58) *
caustic * 0.045;
// Bring the outside light sources continuously across the glass boundary.
color += perimeterReflection * (0.56 + fresnel * 0.64);
color *= mix(0.88, 1.025, geometricNormal.z);
float edgeAlpha = 1.0 - smoothstep(
radius * 0.982,
radius,
distanceToCenter
);
fragColor = vec4(color, edgeAlpha);
}

View File

@ -1109,7 +1109,18 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
) )
: Column( : Column(
children: [ children: [
if (file != null) if (file != null && file.path.isEmpty)
// پیامهای قدیمی که تولیدشان ناموفق بود file
// خالی دارند پخش/دانلود ممکن نیست.
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
child: DidvanText(
'فایل در دسترس نیست. لطفاً دوباره تولید کنید.',
fontSize: 12,
),
)
else if (file != null)
(file.isAudio()) (file.isAudio())
? Padding( ? Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -1157,12 +1168,14 @@ class _AiChatPageState extends State<AiChatPage> with TickerProviderStateMixin {
child: messageFile( child: messageFile(
context, message, state), context, message, state),
), ),
// متنِ همراهِ عکس هم باید نمایش داده شود
// شرط قبلی (!file.isImage()) متنِ کاربر را
// وقتی عکس پیوست بود مخفی میکرد.
if (message.text != null && if (message.text != null &&
message.text!.isNotEmpty && message.text!.isNotEmpty &&
((message.audio == null || ((message.audio == null ||
(message.audio != null && (message.audio != null &&
!message.audio!))) && !message.audio!))))
(file == null || !file.isImage()))
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
vertical: 8.0, horizontal: 16), vertical: 8.0, horizontal: 16),

View File

@ -386,6 +386,10 @@ class _ExploreLatestSlider extends StatelessWidget {
data.content!.link ?? '', data.content!.link ?? '',
description: data.content!.title, description: data.content!.title,
file: fileString, file: fileString,
// Pass new-API UUID so news/radar details use
// getContentDetail instead of the legacy endpoint, which
// fails with "اتصال اینترنت برقرار نیست" for these ids.
contentUuid: data.content!.contentId,
); );
} }
}, },
@ -540,11 +544,21 @@ class _ExploreLatestSliderNew extends StatelessWidget {
onItemTap: (index) { onItemTap: (index) {
if (index < contentSlice.length) { if (index < contentSlice.length) {
final item = contentSlice[index]; final item = contentSlice[index];
// Route to the correct details page based on the content's
// metadata type. 'content' as a nav type isn't handled by
// navigationHandler and produces the "اتصال اینترنت برقرار
// نیست" empty state.
final metaType = (item.metaType ?? '').toLowerCase();
final navType =
(metaType == 'threat' || metaType == 'opportunity')
? 'radar'
: 'news';
context.read<MainPageState>().navigationHandler( context.read<MainPageState>().navigationHandler(
'content', navType,
item.id, item.id.hashCode,
null, null,
description: item.title, description: item.title,
contentUuid: item.id,
); );
} }
}, },
@ -662,12 +676,22 @@ class _NewContentCard extends StatelessWidget {
margin: const EdgeInsets.symmetric(vertical: 6), margin: const EdgeInsets.symmetric(vertical: 6),
child: InkWell( child: InkWell(
borderRadius: DesignConfig.mediumBorderRadius, borderRadius: DesignConfig.mediumBorderRadius,
onTap: () => context.read<MainPageState>().navigationHandler( onTap: () {
'content', // See _ExploreLatestSliderNew route by metaType, pass UUID so
item.id, // details uses the new-API path.
null, final metaType = (item.metaType ?? '').toLowerCase();
description: item.title, final navType =
), (metaType == 'threat' || metaType == 'opportunity')
? 'radar'
: 'news';
context.read<MainPageState>().navigationHandler(
navType,
item.id.hashCode,
null,
description: item.title,
contentUuid: item.id,
);
},
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Column( child: Column(

View File

@ -931,6 +931,7 @@ class MainPageState extends CoreProvier {
String? link, { String? link, {
String? description, String? description,
String? file, String? file,
String? contentUuid,
}) { }) {
link = link ?? ''; link = link ?? '';
final context = navigatorKey.currentContext; final context = navigatorKey.currentContext;
@ -951,6 +952,12 @@ class MainPageState extends CoreProvier {
Navigator.of(context).pushNamed(Routes.newsDetails, arguments: { Navigator.of(context).pushNamed(Routes.newsDetails, arguments: {
'onMarkChanged': (id, value) => markChangeHandler(type, id, value), 'onMarkChanged': (id, value) => markChangeHandler(type, id, value),
'id': id is int ? id : 0, 'id': id is int ? id : 0,
// New-API UUID: forces getContentDetail path in details state.
// Without this, details tries the legacy endpoint with the
// hashCode-derived id, gets a network/404 failure, and the page
// shows "اتصال اینترنت برقرار نیست".
if (contentUuid != null && contentUuid.isNotEmpty)
'contentUuid': contentUuid,
'args': const NewsRequestArgs(page: 0), 'args': const NewsRequestArgs(page: 0),
'hasUnmarkConfirmation': false, 'hasUnmarkConfirmation': false,
'description': description, 'description': description,
@ -960,6 +967,8 @@ class MainPageState extends CoreProvier {
Navigator.of(context).pushNamed(Routes.radarDetails, arguments: { Navigator.of(context).pushNamed(Routes.radarDetails, arguments: {
'onMarkChanged': (id, value) => markChangeHandler(type, id, value), 'onMarkChanged': (id, value) => markChangeHandler(type, id, value),
'id': id is int ? id : 0, 'id': id is int ? id : 0,
if (contentUuid != null && contentUuid.isNotEmpty)
'contentUuid': contentUuid,
'args': const RadarRequestArgs(page: 0), 'args': const RadarRequestArgs(page: 0),
'hasUnmarkConfirmation': false, 'hasUnmarkConfirmation': false,
'description': description, 'description': description,

View File

@ -85,6 +85,11 @@ class SimpleExploreCard extends StatelessWidget {
content!.link ?? '', content!.link ?? '',
file: content!.file, file: content!.file,
description: content!.title, description: content!.title,
// Forward the new-API UUID so news/radar details opens via
// getContentDetail. Without this the SimpleExploreCard tap
// fell into the legacy `/news/<hashCode>` path and showed
// the "اتصال اینترنت برقرار نیست" empty state.
contentUuid: content!.contentId,
); );
} }
}, },

View File

@ -154,7 +154,17 @@ class SearchResultItem extends StatelessWidget {
_targetPageRouteName!, _targetPageRouteName!,
arguments: { arguments: {
'id': item.id, 'id': item.id,
// Pass the new-API UUID so news/radar details uses
// getContentDetail (new API) instead of getNewsDetails (legacy)
// the legacy endpoint doesn't know these hashCode-derived
// ids and returns 404, showing the "تلاش مجدد" retry state.
if (item.keycloakId != null && item.keycloakId!.isNotEmpty)
'contentUuid': item.keycloakId,
'args': _targetPageArgs, 'args': _targetPageArgs,
// Details pages call these unconditionally pass no-ops so
// they don't crash / fall into an error state.
'onMarkChanged': (int _, bool __) {},
'hasUnmarkConfirmation': false,
}, },
); );
} }

View File

@ -107,9 +107,10 @@ class NewsDetailsState extends CoreProvier {
_currentIndex--; _currentIndex--;
} }
isFetchingNewItem = false; isFetchingNewItem = false;
if (currentNews.contents.length == 1) { // Always fetch related content the previous
getRelatedContents(); // `contents.length == 1` gate was a legacy quirk that hid the
} // "مطالب مرتبط" section for almost every news item.
getRelatedContents();
appState = AppState.idle; appState = AppState.idle;
} else { } else {
isFetchingNewItem = false; isFetchingNewItem = false;
@ -405,13 +406,16 @@ class NewsDetailsState extends CoreProvier {
_markRelatedEmpty(); _markRelatedEmpty();
return; return;
} }
// امتیازدهی بر اساس تعداد تگهای مشترک. // Backend already filtered by tag name don't re-filter here.
// Persian text normalization (Arabic vs Persian YEH, ZWNJ) makes
// exact string equality unreliable and would drop legitimate matches.
// Rank by shared-tag count when we can, but keep everything else.
final tagSet = tagNames.toSet(); final tagSet = tagNames.toSet();
final scored = <MapEntry<int, dynamic>>[]; final scored = <MapEntry<int, dynamic>>[];
for (final c in res.data!.data) { for (final c in res.data!.data) {
if (c.id.hashCode == currentNews.id) continue; if (c.id.hashCode == currentNews.id) continue;
final shared = c.tagNames.where(tagSet.contains).length; final shared = c.tagNames.where(tagSet.contains).length;
if (shared > 0) scored.add(MapEntry(shared, c)); scored.add(MapEntry(shared, c));
} }
scored.sort((a, b) => b.key.compareTo(a.key)); scored.sort((a, b) => b.key.compareTo(a.key));
final target = news.firstWhere((e) => e?.id == currentNews.id); final target = news.firstWhere((e) => e?.id == currentNews.id);

View File

@ -117,9 +117,9 @@ class RadarDetailsState extends CoreProvier {
_currentIndex--; _currentIndex--;
} }
isFetchingNewItem = false; isFetchingNewItem = false;
if (currentRadar.contents.length == 1) { // Always fetch related content see news_details_state for the
getRelatedContents(); // same fix. contents.length gate was hiding مطالب مرتبط.
} getRelatedContents();
appState = AppState.idle; appState = AppState.idle;
} else { } else {
isFetchingNewItem = false; isFetchingNewItem = false;
@ -197,12 +197,15 @@ class RadarDetailsState extends CoreProvier {
_markRelatedEmpty(); _markRelatedEmpty();
return; return;
} }
// Backend already filtered by tag name don't re-filter here.
// Persian text normalization (Arabic vs Persian YEH, ZWNJ) makes
// exact string equality unreliable and would drop legitimate matches.
final tagSet = tagNames.toSet(); final tagSet = tagNames.toSet();
final scored = <MapEntry<int, dynamic>>[]; final scored = <MapEntry<int, dynamic>>[];
for (final c in res.data!.data) { for (final c in res.data!.data) {
if (c.id.hashCode == currentRadar.id) continue; if (c.id.hashCode == currentRadar.id) continue;
final shared = c.tagNames.where(tagSet.contains).length; final shared = c.tagNames.where(tagSet.contains).length;
if (shared > 0) scored.add(MapEntry(shared, c)); scored.add(MapEntry(shared, c));
} }
scored.sort((a, b) => b.key.compareTo(a.key)); scored.sort((a, b) => b.key.compareTo(a.key));
final target = radars.firstWhere((e) => e?.id == currentRadar.id); final target = radars.firstWhere((e) => e?.id == currentRadar.id);

File diff suppressed because it is too large Load Diff

View File

@ -142,6 +142,7 @@ dev_dependencies:
flutter: flutter:
shaders: shaders:
- lib/shaders/liquid_glass_lens.frag - lib/shaders/liquid_glass_lens.frag
- lib/shaders/ai_orb_3d.frag
# - lib/shaders/static_liquid.frag # - lib/shaders/static_liquid.frag
# - lib/shaders/water_ripple.frag # - lib/shaders/water_ripple.frag
# The following line ensures that the Material Icons font is # The following line ensures that the Material Icons font is