1307 lines
45 KiB
Swift
1307 lines
45 KiB
Swift
//
|
||
// InsightView.swift
|
||
// EmotionMuseum
|
||
//
|
||
// Created by EmotionMuseum on 2024/01/01.
|
||
//
|
||
|
||
import SwiftUI
|
||
import Foundation
|
||
import AVFoundation
|
||
#if canImport(UIKit)
|
||
import UIKit
|
||
#endif
|
||
#if canImport(AppKit)
|
||
import AppKit
|
||
#endif
|
||
|
||
// MARK: - 主题颜色配置
|
||
struct ThemeColors {
|
||
static let wechatGreen = Color(red: 0.1, green: 0.7, blue: 0.3)
|
||
static let lightBackground = Color(red: 0.95, green: 0.95, blue: 0.97)
|
||
static let darkBackground = Color(red: 0.1, green: 0.1, blue: 0.12)
|
||
|
||
#if canImport(UIKit)
|
||
static let cardBackground = Color(UIColor.systemBackground)
|
||
static let secondaryBackground = Color(UIColor.secondarySystemBackground)
|
||
static let textPrimary = Color(UIColor.label)
|
||
static let textSecondary = Color(UIColor.secondaryLabel)
|
||
#elseif canImport(AppKit)
|
||
static let cardBackground = Color(NSColor.controlBackgroundColor)
|
||
static let secondaryBackground = Color(NSColor.unemphasizedSelectedContentBackgroundColor)
|
||
static let textPrimary = Color(NSColor.labelColor)
|
||
static let textSecondary = Color(NSColor.secondaryLabelColor)
|
||
#else
|
||
static let cardBackground = Color.white
|
||
static let secondaryBackground = Color.gray.opacity(0.1)
|
||
static let textPrimary = Color.primary
|
||
static let textSecondary = Color.secondary
|
||
#endif
|
||
|
||
static let accent = Color.accentColor
|
||
}
|
||
|
||
// MARK: - 心情数据模型
|
||
struct MoodData: Identifiable {
|
||
let id = UUID()
|
||
let emoji: String
|
||
let name: String
|
||
let color: Color
|
||
}
|
||
|
||
// MARK: - AI对话记录模型
|
||
struct AIConversation: Identifiable {
|
||
let id = UUID()
|
||
let date: Date
|
||
let userMessage: String
|
||
let aiResponse: String
|
||
let mood: String
|
||
let tags: [String]
|
||
}
|
||
|
||
// MARK: - 心情选择弹框
|
||
struct MoodPickerSheet: View {
|
||
@Binding var selectedMood: String
|
||
@Binding var isPresented: Bool
|
||
let selectedDate: Date
|
||
|
||
let moods: [MoodData] = [
|
||
MoodData(emoji: "😊", name: "开心", color: .yellow),
|
||
MoodData(emoji: "😢", name: "难过", color: .blue),
|
||
MoodData(emoji: "😡", name: "愤怒", color: .red),
|
||
MoodData(emoji: "😴", name: "疲惫", color: .gray),
|
||
MoodData(emoji: "🤔", name: "思考", color: .purple),
|
||
MoodData(emoji: "😍", name: "兴奋", color: .pink),
|
||
MoodData(emoji: "😰", name: "焦虑", color: .orange),
|
||
MoodData(emoji: "😌", name: "平静", color: .green)
|
||
]
|
||
|
||
var body: some View {
|
||
NavigationView {
|
||
VStack(spacing: 24) {
|
||
VStack(spacing: 8) {
|
||
Text(DateFormatter.fullDate.string(from: selectedDate))
|
||
.font(.title2)
|
||
.fontWeight(.semibold)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
|
||
Text("选择今日心情")
|
||
.font(.subheadline)
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
}
|
||
.padding(.top)
|
||
|
||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) {
|
||
ForEach(moods) { mood in
|
||
Button(action: {
|
||
selectedMood = mood.emoji
|
||
isPresented = false
|
||
}) {
|
||
VStack(spacing: 12) {
|
||
Text(mood.emoji)
|
||
.font(.system(size: 40))
|
||
|
||
Text(mood.name)
|
||
.font(.headline)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(20)
|
||
.background(mood.color.opacity(0.1))
|
||
.cornerRadius(16)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 16)
|
||
.stroke(mood.color.opacity(0.3), lineWidth: 1)
|
||
)
|
||
}
|
||
.buttonStyle(PlainButtonStyle())
|
||
}
|
||
}
|
||
.padding(.horizontal)
|
||
|
||
Spacer()
|
||
}
|
||
.background(ThemeColors.lightBackground.ignoresSafeArea())
|
||
.navigationTitle("")
|
||
#if os(iOS)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
#endif
|
||
.toolbar {
|
||
#if os(iOS)
|
||
ToolbarItem(placement: .topBarTrailing) {
|
||
Button("取消") {
|
||
isPresented = false
|
||
}
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
}
|
||
#else
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button("取消") {
|
||
isPresented = false
|
||
}
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
}
|
||
#endif
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 单行日历
|
||
struct SingleRowCalendar: View {
|
||
@Binding var selectedDate: Date
|
||
@State private var currentWeekOffset = 0
|
||
@State private var isExpanded = false
|
||
let onDateTap: (Date) -> Void
|
||
|
||
private var calendar: Calendar {
|
||
Calendar.current
|
||
}
|
||
|
||
private var weekDays: [Date] {
|
||
if isExpanded {
|
||
// 展开时显示最近一个月的日期
|
||
let today = Date()
|
||
let startOfMonth = calendar.date(byAdding: .day, value: -30, to: today) ?? today
|
||
return (0..<31).compactMap {
|
||
calendar.date(byAdding: .day, value: $0, to: startOfMonth)
|
||
}
|
||
} else {
|
||
// 收起时显示当前周
|
||
let startOfWeek = calendar.dateInterval(of: .weekOfYear, for: selectedDate)?.start ?? Date()
|
||
return (0..<7).compactMap {
|
||
calendar.date(byAdding: .day, value: $0 + currentWeekOffset * 7, to: startOfWeek)
|
||
}
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(spacing: 12) {
|
||
HStack {
|
||
if !isExpanded {
|
||
Button(action: { currentWeekOffset -= 1 }) {
|
||
Image(systemName: "chevron.left")
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Text(DateFormatter.monthYear.string(from: selectedDate))
|
||
.font(.headline)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
|
||
Spacer()
|
||
|
||
if !isExpanded {
|
||
Button(action: { currentWeekOffset += 1 }) {
|
||
Image(systemName: "chevron.right")
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
}
|
||
}
|
||
|
||
Button(action: {
|
||
withAnimation(.spring()) {
|
||
isExpanded.toggle()
|
||
}
|
||
}) {
|
||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
.padding(.leading, 8)
|
||
}
|
||
}
|
||
|
||
if isExpanded {
|
||
// 展开时显示月份视图
|
||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), spacing: 8) {
|
||
// 星期标题
|
||
ForEach(["日", "一", "二", "三", "四", "五", "六"], id: \.self) { weekday in
|
||
Text(weekday)
|
||
.font(.caption)
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
}
|
||
|
||
// 日期格子
|
||
ForEach(weekDays, id: \.self) { date in
|
||
VStack(spacing: 4) {
|
||
Button(action: {
|
||
selectedDate = date
|
||
onDateTap(date)
|
||
// 选择日期后自动收起
|
||
withAnimation(.spring()) {
|
||
isExpanded = false
|
||
}
|
||
}) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(calendar.isDate(date, inSameDayAs: selectedDate) ?
|
||
ThemeColors.wechatGreen : Color.clear)
|
||
.frame(width: 36, height: 36)
|
||
|
||
Text("\(calendar.component(.day, from: date))")
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(
|
||
calendar.isDate(date, inSameDayAs: selectedDate) ?
|
||
.white : ThemeColors.textPrimary
|
||
)
|
||
}
|
||
}
|
||
|
||
// 心情图标占位
|
||
Text(moodIconForDate(date))
|
||
.font(.caption)
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// 收起时显示周视图
|
||
HStack(spacing: 0) {
|
||
ForEach(weekDays, id: \.self) { date in
|
||
VStack(spacing: 4) {
|
||
Text(DateFormatter.weekday.string(from: date))
|
||
.font(.caption)
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
|
||
Button(action: {
|
||
selectedDate = date
|
||
onDateTap(date)
|
||
}) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(calendar.isDate(date, inSameDayAs: selectedDate) ?
|
||
ThemeColors.wechatGreen : Color.clear)
|
||
.frame(width: 36, height: 36)
|
||
|
||
Text("\(calendar.component(.day, from: date))")
|
||
.font(.system(size: 16, weight: .medium))
|
||
.foregroundColor(
|
||
calendar.isDate(date, inSameDayAs: selectedDate) ?
|
||
.white : ThemeColors.textPrimary
|
||
)
|
||
}
|
||
}
|
||
|
||
// 心情图标占位
|
||
Text(moodIconForDate(date))
|
||
.font(.caption)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding()
|
||
.background(ThemeColors.cardBackground)
|
||
.cornerRadius(16)
|
||
.shadow(color: .black.opacity(0.05), radius: 8, x: 0, y: 2)
|
||
.animation(.spring(), value: currentWeekOffset)
|
||
}
|
||
|
||
private func moodIconForDate(_ date: Date) -> String {
|
||
// 这里可以根据实际数据返回对应日期的心情图标
|
||
let day = Calendar.current.component(.day, from: date)
|
||
let moods = ["😊", "😢", "😡", "😴", "🤔", "😍", "😰"]
|
||
return day % 3 == 0 ? moods[day % moods.count] : ""
|
||
}
|
||
}
|
||
|
||
// MARK: - AI对话卡片
|
||
struct AIConversationCard: View {
|
||
let conversation: AIConversation
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Text(DateFormatter.conversationDate.string(from: conversation.date))
|
||
.font(.caption)
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
Spacer()
|
||
Text(conversation.mood)
|
||
.font(.title3)
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("我: \(conversation.userMessage)")
|
||
.font(.body)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
.padding(12)
|
||
.background(ThemeColors.secondaryBackground)
|
||
.cornerRadius(12)
|
||
|
||
Text("AI: \(conversation.aiResponse)")
|
||
.font(.body)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
.padding(12)
|
||
.background(ThemeColors.wechatGreen.opacity(0.1))
|
||
.cornerRadius(12)
|
||
}
|
||
|
||
if !conversation.tags.isEmpty {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack {
|
||
ForEach(conversation.tags, id: \.self) { tag in
|
||
Text(tag)
|
||
.font(.caption)
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.background(ThemeColors.accent.opacity(0.2))
|
||
.cornerRadius(8)
|
||
}
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
}
|
||
}
|
||
.padding()
|
||
.background(ThemeColors.cardBackground)
|
||
.cornerRadius(16)
|
||
.shadow(color: .black.opacity(0.05), radius: 8, x: 0, y: 2)
|
||
}
|
||
}
|
||
|
||
// MARK: - 音频消息管理器
|
||
class AudioManager: NSObject, ObservableObject, AVAudioRecorderDelegate {
|
||
static let shared = AudioManager()
|
||
|
||
@Published var isRecording = false
|
||
@Published var recordingDuration: TimeInterval = 0
|
||
@Published var currentTime: TimeInterval = 0
|
||
|
||
private var audioRecorder: AVAudioRecorder?
|
||
private var audioPlayer: AVAudioPlayer?
|
||
private var displayLink: CADisplayLink?
|
||
private var recordingStartTime: Date?
|
||
private var durationTimer: Timer?
|
||
|
||
override init() {
|
||
super.init()
|
||
setupAudioSession()
|
||
}
|
||
|
||
private func setupAudioSession() {
|
||
let session = AVAudioSession.sharedInstance()
|
||
do {
|
||
try session.setCategory(.playAndRecord, mode: .default)
|
||
try session.setActive(true)
|
||
} catch {
|
||
print("音频会话设置失败: \(error)")
|
||
}
|
||
}
|
||
|
||
func startRecording() {
|
||
let audioFilename = getDocumentsDirectory().appendingPathComponent("\(UUID().uuidString).m4a")
|
||
|
||
let settings = [
|
||
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||
AVSampleRateKey: 12000,
|
||
AVNumberOfChannelsKey: 1,
|
||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
||
]
|
||
|
||
do {
|
||
audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings)
|
||
audioRecorder?.delegate = self
|
||
audioRecorder?.record()
|
||
isRecording = true
|
||
recordingStartTime = Date()
|
||
|
||
// 开始计时
|
||
startDurationTimer()
|
||
} catch {
|
||
print("录音失败: \(error)")
|
||
}
|
||
}
|
||
|
||
func stopRecording() -> (URL, TimeInterval)? {
|
||
audioRecorder?.stop()
|
||
let url = audioRecorder?.url
|
||
let duration = recordingDuration
|
||
audioRecorder = nil
|
||
isRecording = false
|
||
|
||
// 停止计时
|
||
stopDurationTimer()
|
||
recordingDuration = 0
|
||
|
||
if let url = url {
|
||
return (url, duration)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
private func startDurationTimer() {
|
||
durationTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||
guard let self = self else { return }
|
||
if let startTime = self.recordingStartTime {
|
||
self.recordingDuration = Date().timeIntervalSince(startTime)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopDurationTimer() {
|
||
durationTimer?.invalidate()
|
||
durationTimer = nil
|
||
recordingStartTime = nil
|
||
}
|
||
|
||
func playAudio(url: URL, completion: @escaping () -> Void) {
|
||
stopCurrentAudio()
|
||
|
||
do {
|
||
audioPlayer = try AVAudioPlayer(contentsOf: url)
|
||
audioPlayer?.delegate = self
|
||
audioPlayer?.play()
|
||
|
||
// 开始更新播放进度
|
||
startPlaybackTimer()
|
||
|
||
// 设置播放完成回调
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + (audioPlayer?.duration ?? 0)) {
|
||
completion()
|
||
}
|
||
} catch {
|
||
print("音频播放失败: \(error)")
|
||
}
|
||
}
|
||
|
||
func stopCurrentAudio() {
|
||
audioPlayer?.stop()
|
||
audioPlayer = nil
|
||
stopPlaybackTimer()
|
||
currentTime = 0
|
||
}
|
||
|
||
private func startPlaybackTimer() {
|
||
displayLink = CADisplayLink(target: self, selector: #selector(updatePlaybackTime))
|
||
displayLink?.add(to: .main, forMode: .common)
|
||
}
|
||
|
||
private func stopPlaybackTimer() {
|
||
displayLink?.invalidate()
|
||
displayLink = nil
|
||
}
|
||
|
||
@objc private func updatePlaybackTime() {
|
||
currentTime = audioPlayer?.currentTime ?? 0
|
||
}
|
||
|
||
private func getDocumentsDirectory() -> URL {
|
||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||
}
|
||
}
|
||
|
||
extension AudioManager: AVAudioPlayerDelegate {
|
||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||
stopCurrentAudio()
|
||
}
|
||
}
|
||
|
||
// MARK: - 音频消息视图
|
||
struct AudioMessageView: View {
|
||
let url: URL
|
||
let duration: TimeInterval
|
||
@StateObject private var audioManager = AudioManager.shared
|
||
@State private var isPlaying = false
|
||
@State private var progress: CGFloat = 0
|
||
@State private var isAnimating = false
|
||
|
||
var body: some View {
|
||
Button(action: {
|
||
if isPlaying {
|
||
audioManager.stopCurrentAudio()
|
||
isPlaying = false
|
||
} else {
|
||
isPlaying = true
|
||
audioManager.playAudio(url: url) {
|
||
isPlaying = false
|
||
}
|
||
}
|
||
}) {
|
||
HStack(spacing: 8) {
|
||
// 播放/暂停按钮
|
||
Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||
.font(.title2)
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
|
||
// 音频波形动画
|
||
HStack(spacing: 2) {
|
||
ForEach(0..<5) { index in
|
||
RoundedRectangle(cornerRadius: 1)
|
||
.frame(width: 2, height: isPlaying ? 15 : 8)
|
||
.animation(
|
||
Animation.easeInOut(duration: 0.5)
|
||
.repeatForever()
|
||
.delay(Double(index) * 0.1),
|
||
value: isPlaying
|
||
)
|
||
}
|
||
}
|
||
.frame(width: 80)
|
||
|
||
// 时长显示
|
||
Text(formatDuration(duration))
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.padding()
|
||
.background(Color.secondary.opacity(0.1))
|
||
.cornerRadius(12)
|
||
}
|
||
}
|
||
|
||
private func formatDuration(_ duration: TimeInterval) -> String {
|
||
let minutes = Int(duration) / 60
|
||
let seconds = Int(duration) % 60
|
||
return String(format: "%d:%02d", minutes, seconds)
|
||
}
|
||
}
|
||
|
||
// MARK: - 聊天消息模型
|
||
struct ChatMessage: Identifiable {
|
||
let id: UUID
|
||
let content: String
|
||
let isUser: Bool
|
||
let timestamp: Date
|
||
let messageType: MessageType
|
||
let audioURL: URL?
|
||
let audioDuration: TimeInterval
|
||
|
||
enum MessageType {
|
||
case text
|
||
case audio
|
||
}
|
||
|
||
init(id: UUID = UUID(), content: String, isUser: Bool, timestamp: Date, messageType: MessageType, audioURL: URL? = nil, audioDuration: TimeInterval = 0) {
|
||
self.id = id
|
||
self.content = content
|
||
self.isUser = isUser
|
||
self.timestamp = timestamp
|
||
self.messageType = messageType
|
||
self.audioURL = audioURL
|
||
self.audioDuration = audioDuration
|
||
}
|
||
}
|
||
|
||
// MARK: - 聊天消息视图
|
||
struct ChatMessageView: View {
|
||
let message: ChatMessage
|
||
|
||
var body: some View {
|
||
HStack {
|
||
if message.isUser {
|
||
Spacer()
|
||
}
|
||
|
||
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) {
|
||
if message.messageType == .audio {
|
||
if let audioURL = message.audioURL {
|
||
AudioMessageView(url: audioURL, duration: message.audioDuration)
|
||
}
|
||
} else {
|
||
Text(message.content)
|
||
.padding(12)
|
||
.background(
|
||
message.isUser ?
|
||
ThemeColors.wechatGreen : ThemeColors.secondaryBackground
|
||
)
|
||
.foregroundColor(message.isUser ? .white : ThemeColors.textPrimary)
|
||
.cornerRadius(16)
|
||
}
|
||
|
||
Text(DateFormatter.conversationDate.string(from: message.timestamp))
|
||
.font(.caption2)
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
}
|
||
|
||
if !message.isUser {
|
||
Spacer()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - AI对话视图
|
||
struct AIChatView: View {
|
||
@Binding var isPresented: Bool
|
||
@State private var isVoiceMode = false
|
||
@State private var inputText = ""
|
||
@State private var chatMessages: [ChatMessage] = []
|
||
@StateObject private var audioManager = AudioManager.shared
|
||
let initialMessage: String
|
||
@State private var recordingFeedback = false
|
||
|
||
private let aiResponses = [
|
||
"我理解你现在的感受。让我们一起探讨这个问题,看看有什么可以帮助你的方式。",
|
||
"你说得很有道理。这种情况下,我建议你可以试着换个角度来看待这个问题。",
|
||
"听起来这确实是个令人困扰的情况。不过别担心,我们可以一步一步地来解决它。",
|
||
"你的感受是完全正常的。在这种情况下,很多人都会有类似的反应。",
|
||
"谢谢你愿意跟我分享这些。让我们一起来分析一下,看看有什么可以改善的地方。"
|
||
]
|
||
|
||
var body: some View {
|
||
NavigationView {
|
||
VStack(spacing: 0) {
|
||
// 聊天消息列表
|
||
ScrollView {
|
||
LazyVStack(spacing: 16) {
|
||
ForEach(chatMessages) { message in
|
||
ChatMessageView(message: message)
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
|
||
// 输入区域
|
||
VStack(spacing: 12) {
|
||
if isVoiceMode {
|
||
// 语音输入按钮
|
||
voiceButton
|
||
} else {
|
||
// 文字输入框
|
||
HStack(spacing: 12) {
|
||
TextField("输入消息...", text: $inputText)
|
||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||
.padding(.vertical, 8)
|
||
|
||
Button(action: sendTextMessage) {
|
||
Image(systemName: "arrow.up.circle.fill")
|
||
.font(.title2)
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
}
|
||
.disabled(inputText.isEmpty)
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
.background(Color(UIColor.systemBackground))
|
||
.shadow(color: .black.opacity(0.05), radius: 8, y: -4)
|
||
}
|
||
.navigationTitle("AI疗愈师")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .navigationBarLeading) {
|
||
Button(action: { isVoiceMode.toggle() }) {
|
||
Image(systemName: isVoiceMode ? "keyboard" : "mic")
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
}
|
||
}
|
||
|
||
ToolbarItem(placement: .navigationBarTrailing) {
|
||
Button(action: { isPresented = false }) {
|
||
Image(systemName: "chevron.down")
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
if !initialMessage.isEmpty {
|
||
let userMessage = ChatMessage(
|
||
id: UUID(),
|
||
content: initialMessage,
|
||
isUser: true,
|
||
timestamp: Date(),
|
||
messageType: .text,
|
||
audioURL: nil,
|
||
audioDuration: 0
|
||
)
|
||
chatMessages.append(userMessage)
|
||
sendAIResponse()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var voiceButton: some View {
|
||
Button(action: handleVoiceButton) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(audioManager.isRecording ? Color.red : ThemeColors.wechatGreen)
|
||
.frame(width: 60, height: 60)
|
||
.scaleEffect(recordingFeedback ? 1.2 : 1.0)
|
||
.animation(.spring(response: 0.3), value: recordingFeedback)
|
||
|
||
Image(systemName: audioManager.isRecording ? "stop.fill" : "mic.fill")
|
||
.foregroundColor(.white)
|
||
.font(.title2)
|
||
}
|
||
}
|
||
.padding(.vertical)
|
||
.overlay(
|
||
Group {
|
||
if audioManager.isRecording {
|
||
VStack {
|
||
Text(formatDuration(audioManager.recordingDuration))
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
.padding(.vertical, 4)
|
||
|
||
// 录音波形动画
|
||
HStack(spacing: 4) {
|
||
ForEach(0..<3) { index in
|
||
Circle()
|
||
.fill(Color.red)
|
||
.frame(width: 6, height: 6)
|
||
.scaleEffect(recordingFeedback ? 1 : 0.5)
|
||
.animation(
|
||
Animation.easeInOut(duration: 0.5)
|
||
.repeatForever()
|
||
.delay(Double(index) * 0.2),
|
||
value: recordingFeedback
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.offset(y: -40)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
private func handleVoiceButton() {
|
||
if audioManager.isRecording {
|
||
if let (audioURL, duration) = audioManager.stopRecording() {
|
||
let message = ChatMessage(
|
||
id: UUID(),
|
||
content: "",
|
||
isUser: true,
|
||
timestamp: Date(),
|
||
messageType: .audio,
|
||
audioURL: audioURL,
|
||
audioDuration: duration
|
||
)
|
||
chatMessages.append(message)
|
||
sendAIResponse()
|
||
}
|
||
recordingFeedback = false
|
||
} else {
|
||
audioManager.startRecording()
|
||
recordingFeedback = true
|
||
}
|
||
}
|
||
|
||
private func sendTextMessage() {
|
||
guard !inputText.isEmpty else { return }
|
||
|
||
let message = ChatMessage(
|
||
id: UUID(),
|
||
content: inputText,
|
||
isUser: true,
|
||
timestamp: Date(),
|
||
messageType: .text,
|
||
audioURL: nil,
|
||
audioDuration: 0
|
||
)
|
||
chatMessages.append(message)
|
||
inputText = ""
|
||
sendAIResponse()
|
||
}
|
||
|
||
private func sendAIResponse() {
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||
let aiMessage = ChatMessage(
|
||
id: UUID(),
|
||
content: generateAIResponse(),
|
||
isUser: false,
|
||
timestamp: Date(),
|
||
messageType: .text,
|
||
audioURL: nil,
|
||
audioDuration: 0
|
||
)
|
||
chatMessages.append(aiMessage)
|
||
}
|
||
}
|
||
|
||
private func generateAIResponse() -> String {
|
||
return aiResponses.randomElement() ?? aiResponses[0]
|
||
}
|
||
|
||
private func formatDuration(_ duration: TimeInterval) -> String {
|
||
let minutes = Int(duration) / 60
|
||
let seconds = Int(duration) % 60
|
||
return String(format: "%d:%02d", minutes, seconds)
|
||
}
|
||
}
|
||
|
||
// MARK: - 主视图
|
||
struct InsightView: View {
|
||
@State private var selectedDate = Date()
|
||
@State private var selectedMood = ""
|
||
@State private var emotionText = ""
|
||
@State private var emotionScore = 5.0
|
||
@State private var selectedTags: Set<String> = []
|
||
@State private var showingAIAnalysis = false
|
||
@State private var isAnalyzing = false
|
||
@State private var aiResponse = ""
|
||
@State private var showingSettings = false
|
||
@State private var showingMoodPicker = false
|
||
@State private var conversations: [AIConversation] = []
|
||
@State private var showingConversationHistory = false
|
||
@State private var showingAIChat = false
|
||
|
||
let availableTags = ["工作", "学习", "家庭", "朋友", "健康", "爱情", "财务", "娱乐"]
|
||
|
||
var body: some View {
|
||
NavigationView {
|
||
mainContent
|
||
}
|
||
.sheet(isPresented: $showingSettings) {
|
||
InsightSettingsView()
|
||
}
|
||
.sheet(isPresented: $showingMoodPicker) {
|
||
MoodPickerSheet(
|
||
selectedMood: $selectedMood,
|
||
isPresented: $showingMoodPicker,
|
||
selectedDate: selectedDate
|
||
)
|
||
}
|
||
.sheet(isPresented: $showingConversationHistory) {
|
||
NavigationView {
|
||
ScrollView {
|
||
ConversationHistoryView(conversations: conversations)
|
||
}
|
||
.navigationTitle("AI对话历史")
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("关闭") {
|
||
showingConversationHistory = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.fullScreenCover(isPresented: $showingAIChat) {
|
||
AIChatView(isPresented: $showingAIChat, initialMessage: emotionText)
|
||
}
|
||
}
|
||
|
||
private var mainContent: some View {
|
||
ScrollView {
|
||
VStack(spacing: 16) {
|
||
// 导航栏
|
||
navigationHeader
|
||
.padding(.horizontal)
|
||
|
||
// 单行日历
|
||
calendarSection
|
||
.padding(.horizontal)
|
||
|
||
// 标签选择
|
||
tagsSection
|
||
.padding(.horizontal)
|
||
|
||
// 情感输入
|
||
emotionInputSection
|
||
.padding(.horizontal)
|
||
|
||
// AI分析按钮
|
||
aiAnalysisButton
|
||
.padding(.horizontal)
|
||
|
||
// 分析结果
|
||
if showingAIAnalysis {
|
||
aiAnalysisResultSection
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
Spacer(minLength: 20)
|
||
}
|
||
}
|
||
.background(ThemeColors.lightBackground.ignoresSafeArea())
|
||
}
|
||
|
||
private var calendarSection: some View {
|
||
SingleRowCalendar(selectedDate: $selectedDate) { date in
|
||
selectedDate = date
|
||
showingMoodPicker = true
|
||
}
|
||
}
|
||
|
||
// MARK: - 导航栏
|
||
private var navigationHeader: some View {
|
||
HStack {
|
||
Button(action: { showingConversationHistory = true }) {
|
||
Image(systemName: "book.fill")
|
||
.font(.title2)
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Text("情感洞察")
|
||
.font(.title2)
|
||
.fontWeight(.semibold)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
|
||
Spacer()
|
||
|
||
Button(action: { showingSettings = true }) {
|
||
Image(systemName: "gearshape.fill")
|
||
.font(.title2)
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
}
|
||
}
|
||
.padding(.horizontal)
|
||
}
|
||
|
||
// MARK: - 标签选择
|
||
private var tagsSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("相关标签(AI疗愈师3D IP)")
|
||
.font(.headline)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
|
||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 8) {
|
||
ForEach(availableTags, id: \.self) { tag in
|
||
Button(action: {
|
||
if selectedTags.contains(tag) {
|
||
selectedTags.remove(tag)
|
||
} else {
|
||
selectedTags.insert(tag)
|
||
}
|
||
}) {
|
||
Text(tag)
|
||
.font(.caption)
|
||
.padding(.horizontal, 12)
|
||
.padding(.vertical, 6)
|
||
.background(
|
||
selectedTags.contains(tag) ?
|
||
ThemeColors.wechatGreen : ThemeColors.secondaryBackground
|
||
)
|
||
.foregroundColor(
|
||
selectedTags.contains(tag) ? .white : ThemeColors.textPrimary
|
||
)
|
||
.cornerRadius(12)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.padding()
|
||
.background(ThemeColors.cardBackground)
|
||
.cornerRadius(16)
|
||
.shadow(color: .black.opacity(0.05), radius: 8, x: 0, y: 2)
|
||
}
|
||
|
||
// MARK: - 情感输入
|
||
private var emotionInputSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("描述你的感受")
|
||
.font(.headline)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
|
||
TextEditor(text: $emotionText)
|
||
.frame(height: 240)
|
||
.padding(8)
|
||
.background(ThemeColors.secondaryBackground)
|
||
.cornerRadius(12)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 12)
|
||
.stroke(ThemeColors.wechatGreen.opacity(0.3), lineWidth: 1)
|
||
)
|
||
}
|
||
.padding()
|
||
.background(ThemeColors.cardBackground)
|
||
.cornerRadius(16)
|
||
.shadow(color: .black.opacity(0.05), radius: 8, x: 0, y: 2)
|
||
}
|
||
|
||
// MARK: - AI分析按钮
|
||
private var aiAnalysisButton: some View {
|
||
Button(action: {
|
||
if !emotionText.isEmpty {
|
||
showingAIChat = true
|
||
}
|
||
}) {
|
||
HStack {
|
||
Image(systemName: "brain.head.profile")
|
||
.font(.title3)
|
||
Text("AI情感分析")
|
||
.font(.headline)
|
||
.fontWeight(.semibold)
|
||
}
|
||
.foregroundColor(.white)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 50)
|
||
.background(
|
||
LinearGradient(
|
||
colors: [ThemeColors.wechatGreen, ThemeColors.wechatGreen.opacity(0.8)],
|
||
startPoint: .leading,
|
||
endPoint: .trailing
|
||
)
|
||
)
|
||
.cornerRadius(25)
|
||
.shadow(color: ThemeColors.wechatGreen.opacity(0.3), radius: 8, x: 0, y: 4)
|
||
}
|
||
.disabled(emotionText.isEmpty)
|
||
.opacity(emotionText.isEmpty ? 0.6 : 1.0)
|
||
}
|
||
|
||
// MARK: - AI分析结果
|
||
private var aiAnalysisResultSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
HStack {
|
||
Image(systemName: "sparkles")
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
Text("AI分析结果")
|
||
.font(.headline)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
}
|
||
|
||
Text(aiResponse)
|
||
.font(.body)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
.padding()
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background(ThemeColors.wechatGreen.opacity(0.1))
|
||
.cornerRadius(12)
|
||
|
||
Button("保存记录") {
|
||
saveEmotionRecord()
|
||
}
|
||
.font(.subheadline)
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
.frame(maxWidth: .infinity)
|
||
.frame(height: 44)
|
||
.background(ThemeColors.wechatGreen.opacity(0.1))
|
||
.cornerRadius(12)
|
||
}
|
||
.padding()
|
||
.background(ThemeColors.cardBackground)
|
||
.cornerRadius(16)
|
||
.shadow(color: .black.opacity(0.05), radius: 8, x: 0, y: 2)
|
||
}
|
||
|
||
// MARK: - 方法
|
||
private func getRandomGreeting() -> String {
|
||
let greetings = [
|
||
"今天的心情如何?",
|
||
"让我们一起探索内心世界",
|
||
"记录此刻的感受",
|
||
"你的情感值得被倾听",
|
||
"每一种情绪都有它的意义"
|
||
]
|
||
return greetings.randomElement() ?? greetings[0]
|
||
}
|
||
|
||
private func analyzeEmotion() {
|
||
guard !emotionText.isEmpty else { return }
|
||
|
||
isAnalyzing = true
|
||
|
||
// 模拟AI分析过程
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||
aiResponse = generateAIResponse()
|
||
showingAIAnalysis = true
|
||
isAnalyzing = false
|
||
}
|
||
}
|
||
|
||
private func generateAIResponse() -> String {
|
||
let responses = [
|
||
"从你的描述中,我感受到了复杂的情感层次。这种感受是完全正常的,建议你可以通过深呼吸来缓解当前的情绪状态。",
|
||
"你的情感表达很真诚。每个人都会经历情绪的起伏,重要的是学会接纳和理解自己的感受。",
|
||
"我注意到你提到的几个关键词,这些都反映了你内心的真实想法。建议你可以尝试写日记来进一步整理思绪。",
|
||
"你的情感很丰富,这说明你是一个敏感且有深度的人。建议适当的运动和音乐可以帮助调节情绪。"
|
||
]
|
||
return responses.randomElement() ?? responses[0]
|
||
}
|
||
|
||
private func saveEmotionRecord() {
|
||
let newConversation = AIConversation(
|
||
date: selectedDate,
|
||
userMessage: emotionText,
|
||
aiResponse: aiResponse,
|
||
mood: selectedMood,
|
||
tags: Array(selectedTags)
|
||
)
|
||
|
||
conversations.insert(newConversation, at: 0)
|
||
|
||
// 重置输入
|
||
emotionText = ""
|
||
selectedMood = ""
|
||
selectedTags.removeAll()
|
||
emotionScore = 5.0
|
||
showingAIAnalysis = false
|
||
aiResponse = ""
|
||
}
|
||
}
|
||
|
||
// MARK: - 设置页面
|
||
struct InsightSettingsView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
@State private var selectedTheme = 0
|
||
@State private var musicVolume = 0.5
|
||
@State private var soundEffectVolume = 0.7
|
||
|
||
let themes = ["自动", "浅色", "深色"]
|
||
|
||
var body: some View {
|
||
NavigationView {
|
||
List {
|
||
Section("主题设置") {
|
||
SettingRow(title: "界面主题", value: themes[selectedTheme]) {
|
||
// 主题选择逻辑
|
||
}
|
||
}
|
||
|
||
Section("音频设置") {
|
||
VStack {
|
||
SettingRow(title: "背景音乐", value: "\(Int(musicVolume * 100))%") {}
|
||
Slider(value: $musicVolume, in: 0...1)
|
||
.accentColor(ThemeColors.wechatGreen)
|
||
}
|
||
|
||
VStack {
|
||
SettingRow(title: "音效音量", value: "\(Int(soundEffectVolume * 100))%") {}
|
||
Slider(value: $soundEffectVolume, in: 0...1)
|
||
.accentColor(ThemeColors.wechatGreen)
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("设置")
|
||
#if os(iOS)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
#endif
|
||
.toolbar {
|
||
ToolbarItem(placement: {
|
||
#if os(iOS)
|
||
return .topBarTrailing
|
||
#elseif os(macOS)
|
||
return .automatic
|
||
#else
|
||
return .automatic
|
||
#endif
|
||
}()) {
|
||
Button("完成") {
|
||
dismiss()
|
||
}
|
||
.foregroundColor(ThemeColors.wechatGreen)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 设置行组件
|
||
struct SettingRow: View {
|
||
let title: String
|
||
let value: String
|
||
let action: () -> Void
|
||
|
||
var body: some View {
|
||
HStack {
|
||
Text(title)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
Spacer()
|
||
Text(value)
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
Image(systemName: "chevron.right")
|
||
.font(.caption)
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
}
|
||
.contentShape(Rectangle())
|
||
.onTapGesture(perform: action)
|
||
}
|
||
}
|
||
|
||
// MARK: - 情感记录卡片
|
||
struct EmotionRecordCard: View {
|
||
let record: AIConversation
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Text(record.mood)
|
||
.font(.title2)
|
||
Spacer()
|
||
Text(DateFormatter.shortDate.string(from: record.date))
|
||
.font(.caption)
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
}
|
||
|
||
Text(record.userMessage)
|
||
.font(.body)
|
||
.foregroundColor(ThemeColors.textPrimary)
|
||
.lineLimit(3)
|
||
}
|
||
.padding()
|
||
.background(ThemeColors.cardBackground)
|
||
.cornerRadius(12)
|
||
.shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2)
|
||
}
|
||
}
|
||
|
||
// MARK: - 日期格式化扩展
|
||
extension DateFormatter {
|
||
static let monthYear: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy年M月"
|
||
return formatter
|
||
}()
|
||
|
||
static let weekday: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "E"
|
||
return formatter
|
||
}()
|
||
|
||
static let conversationDate: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "M月d日 HH:mm"
|
||
return formatter
|
||
}()
|
||
|
||
static let fullDate: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "yyyy年M月d日"
|
||
return formatter
|
||
}()
|
||
|
||
static let shortDate: DateFormatter = {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "M/d"
|
||
return formatter
|
||
}()
|
||
}
|
||
|
||
// MARK: - 预览
|
||
struct InsightView_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
InsightView()
|
||
.preferredColorScheme(.light)
|
||
|
||
InsightView()
|
||
.preferredColorScheme(.dark)
|
||
}
|
||
}
|
||
|
||
// MARK: - 对话历史视图
|
||
struct ConversationHistoryView: View {
|
||
let conversations: [AIConversation]
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
if conversations.isEmpty {
|
||
VStack {
|
||
Image(systemName: "message.circle")
|
||
.font(.largeTitle)
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
Text("暂无对话记录")
|
||
.font(.subheadline)
|
||
.foregroundColor(ThemeColors.textSecondary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(40)
|
||
} else {
|
||
ForEach(conversations) { conversation in
|
||
AIConversationCard(conversation: conversation)
|
||
}
|
||
}
|
||
}
|
||
.padding()
|
||
.background(ThemeColors.cardBackground)
|
||
.cornerRadius(16)
|
||
.shadow(color: .black.opacity(0.05), radius: 8, x: 0, y: 2)
|
||
}
|
||
}
|