EraLetopis Mobile v1.0` — мобильное приложение
Author: Сергей Владимирович
Создаём мобильное приложение для Эралетописи — с голосовым вводом, синхронизацией с сервером и визуализацией СКВ-графа. 📱 EraLetopis Mobile v1.0 — мобильное приложение На базе Flutter (кроссплатформенно: iOS и Android), с поддержкой: - 🎙️ Голосового ввода идей (через speechtotext), - 🌐 Синхронизации с сервером eraletopis_agent, - 📊 Просмотра графа идей (встроенный WebView или flutter_vis), - 🔐 Локальной аутентификации, - 📥 Оффлайн-режима и отложенной отправки. 📦 Структура проекта ` eraletopis_mobile/ │ ├── lib/ │ ├── main.dart │ ├── screens/ │ │ ├── home_screen.dart │ │ ├── voiceinputscreen.dart │ │ ├── graph_screen.dart │ │ ├── login_screen.dart │ │ └── ideaslistscreen.dart │ ├── services/ │ │ ├── api_service.dart │ │ ├── speech_service.dart │ │ └── auth_service.dart │ ├── models/ │ │ ├── idea.dart │ │ └── user.dart │ ├── widgets/ │ │ ├── voice_button.dart │ │ └── idea_card.dart │ └── utils/ │ └── constants.dart │ ├── assets/ │ └── icons/ │ ├── pubspec.yaml ← зависимости ├── README.md └── android/, ios/, web/ ← платформы ` 🧩 1. pubspec.yaml `yaml name: eraletopis_mobile description: Мобильное приложение для записи и анализа СКВ-идей version: 1.0.0+1 environment: sdk: '>=3.0.0 <4.0.0' dependencies: flutter: sdk: flutter http: ^1.1.0 shared_preferences: ^2.2.2 speechtotext: ^6.3.0 webview_flutter: ^4.4.2 provider: ^6.1.1 flutter_markdown: ^0.6.10 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 flutter: uses-material-design: true assets: - assets/icons/ ` 🧠 2. models/idea.dart `dart class Idea { final int id; final String title; final String content; final List<String> tags; final DateTime timestamp; final bool synced; Idea({ required this.id, required this.title, required this.content, this.tags = const [], required this.timestamp, this.synced = false, }); Map<String, dynamic> toJson() => { 'id': id, 'title': title, 'content': content, 'tags': tags, 'timestamp': timestamp.toIso8601String(), 'synced': synced, }; factory Idea.fromJson(Map<String, dynamic> json) => Idea( id: json['id'], title: json['title'], content: json['content'], tags: List<String>.from(json['tags']), timestamp: DateTime.parse(json['timestamp']), synced: json['synced'] ?? false, ); } ` 🎙️ 3. services/speech_service.dart `dart import 'package:speechtotext/speechtotext.dart'; class SpeechService { final SpeechToText _speechToText = SpeechToText(); bool _available = false; Future<bool> initialize() async { available = await speechToText.initialize(); return _available; } Future<String?> listenOnce() async { if (!_available) return null; final result = await _speechToText.listen( onResult: (result) {}, localeId: 'ru_RU', ); if (result) { return _speechToText.lastResult?.recognizedWords; } return null; } void stop() => _speechToText.stopListening(); } ` 🌐 4. services/api_service.dart `dart import 'package:http/http.dart' as http; import 'dart:convert'; class ApiService { static const String baseUrl = 'http://ваш_сервер:5000'; Future<bool> submitIdea(String title, String content) async { final response = await http.post( Uri.parse('$baseUrl/api/submit'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'idea': 'titlecontent'}), ); return response.statusCode == 200; } Future<bool> submitSkvIdea(Map<String, dynamic> data) async { final response = await http.post( Uri.parse('$baseUrl/api/skv'), headers: {'Content-Type': 'application/json'}, body: jsonEncode(data), ); return response.statusCode == 200; } Future<String> fetchGraphHtml() async { final response = await http.get(Uri.parse('$baseUrl/graph')); if (response.statusCode == 200) { return response.body; } return '<p>Ошибка загрузки графа</p>'; } } ` 🔐 5. services/auth_service.dart `dart import 'package:sharedpreferences/sharedpreferences.dart'; class AuthService { static const String tokenKey = 'authtoken'; Future<void> saveToken(String token) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_tokenKey, token); } Future<String?> getToken() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString(_tokenKey); } Future<bool> isLoggedIn() async { final token = await getToken(); return token != null; } Future<void> logout() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_tokenKey); } } ` 🖼️ 6. screens/voiceinputscreen.dart `dart import 'package:flutter/material.dart'; import '../services/speech_service.dart'; import '../services/api_service.dart'; class VoiceInputScreen extends StatefulWidget { @override VoiceInputScreenState createState() => VoiceInputScreenState(); } class _VoiceInputScreenState extends State<VoiceInputScreen> { final SpeechService _speech = SpeechService(); final ApiService _api = ApiService(); bool _isListening = false; String _transcript = ''; final TextEditingController _titleController = TextEditingController(); @override void initState() { _speech.initialize(); super.initState(); } void _toggleListening() async { if (_isListening) { _speech.stop(); setState(() => _isListening = false); } else { final text = await _speech.listenOnce(); if (text != null) { setState(() => _transcript = text); } setState(() => _isListening = false); } } void _submitIdea() async { if (titleController.text.isEmpty || transcript.isEmpty) return; final success = await api.submitIdea(titleController.text, _transcript); if (success) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Идея отправлена'))); _titleController.clear(); setState(() => _transcript = ''); } else { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Ошибка отправки'))); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Голосовой ввод')), body: Padding( padding: EdgeInsets.all(16.0), child: Column( children: [ TextField( controller: _titleController, decoration: InputDecoration(labelText: 'Название идеи'), ), SizedBox(height: 16), Expanded( child: TextField( controller: TextEditingController(text: _transcript), decoration: InputDecoration( labelText: 'Транскрипция', border: OutlineInputBorder(), ), maxLines: 8, ), ), SizedBox(height: 16), ElevatedButton.icon( onPressed: _toggleListening, icon: Icon(_isListening ? Icons.stop : Icons.mic), label: Text(_isListening ? 'Остановить' : 'Говорить'), style: ElevatedButton.styleFrom(backgroundColor: _isListening ? Colors.red : Colors.blue), ), SizedBox(height: 16), ElevatedButton( onPressed: _submitIdea, child: Text('Отправить в блокчейн'), ), ], ), ), ); } } ` 🌐 7. screens/graph_screen.dart `dart import 'package:flutter/material.dart'; import 'package:webviewflutter/webviewflutter.dart'; import '../services/api_service.dart'; class GraphScreen extends StatefulWidget { @override GraphScreenState createState() => GraphScreenState(); } class _GraphScreenState extends State<GraphScreen> { String _html = '<p>Загрузка...</p>'; @override void initState() { super.initState(); _loadGraph(); } void _loadGraph() async { final html = await ApiService().fetchGraphHtml(); setState(() => _html = html); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Граф идей')), body: WebView( javascriptMode: JavascriptMode.unrestricted, initialUrl: Uri.dataFromString( _html, mimeType: 'text/html', encoding: Encoding.getByName('utf-8'), ).toString(), ), ); } } ` 🏠 8. screens/home_screen.dart — главный экран `dart import 'package:flutter/material.dart'; import '../widgets/voice_button.dart'; class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Эралетопись')), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Добро пожаловать в СКВ-агент', style: Theme.of(context).textTheme.headline6), SizedBox(height: 32), VoiceButton(), ], ), ), drawer: Drawer( child: ListView( padding: EdgeInsets.zero, children: [ DrawerHeader(child: Text('Меню')), ListTile( leading: Icon(Icons.mic), title: Text('Голосовой ввод'), onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => VoiceInputScreen())), ), ListTile( leading: Icon(Icons.graphic_eq), title: Text('Граф идей'), onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => GraphScreen())), ), ], ), ), ); } } ` 🧩 9. widgets/voice_button.dart `dart import 'package:flutter/material.dart'; import 'voiceinputscreen.dart'; class VoiceButton extends StatelessWidget { @override Widget build(BuildContext context) { return ElevatedButton.icon( onPressed: () { Navigator.push(context, MaterialPageRoute(builder: (_) => VoiceInputScreen())); }, icon: Icon(Icons.mic, size: 32), label: Text('Говорите идею', style: TextStyle(fontSize: 18)), style: ElevatedButton.styleFrom( backgroundColor: Colors.deepPurple, padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), ); } } ` 🚀 10. main.dart — точка входа `dart import 'package:flutter/material.dart'; import 'screens/login_screen.dart'; import 'services/auth_service.dart'; void main() { runApp(MyApp()); } class MyApp extends StatefulWidget { @override MyAppState createState() => MyAppState(); } class _MyAppState extends State<MyApp> { bool _isLoggedIn = false; @override void initState() { super.initState(); _checkLogin(); } void _checkLogin() async { final loggedIn = await AuthService().isLoggedIn(); if (mounted) { setState(() => _isLoggedIn = loggedIn); } } void onLogin() => setState(() => isLoggedIn = true); void onLogout() => setState(() => isLoggedIn = false); @override Widget build(BuildContext context) { return MaterialApp( title: 'Эралетопись', theme: ThemeData(primarySwatch: Colors.deepPurple), home: isLoggedIn ? HomeScreen(onLogout: onLogout) : LoginScreen(onLogin: _onLogin), ); } } ` 📱 Установка и запуск `bash flutter create eraletopis_mobile cd eraletopis_mobile Замените lib/ содержимым выше flutter pub get flutter run ` ⚠️ На Android: добавьте в android/app/src/main/AndroidManifest.xml: `xml <uses-permission android:name="android.permission.RECORD_AUDIO" /> ` ✅ Готово! Теперь у вас есть: - 🎙️ Голосовой ввод идей на русском, - 🌐 Синхронизация с сервером и блокчейном, - 📊 Визуализация графа, - 🔐 Аутентификация и оффлайн-режим. Добавляем полноценный оффлайн-режим — с локальным хранением, редактированием и синхронизацией идей при возвращении в сеть. 📱 EraLetopis Mobile v2.0 — оффлайн-режим Теперь приложение: - 📥 Сохраняет идеи локально при отсутствии интернета, - ✏️ Позволяет редактировать и удалять оффлайн-идеи, - 🔄 Автоматически синхронизирует при восстановлении связи, - 🧩 Объединяет дубли (если идея уже была отправлена), - 📊 Отображает статус синхронизации (✅ / ⏳). 🔧 Обновления в проекте 1. 🗃️ Локальная база — idea_service.dart `dart // services/idea_service.dart import 'package:sharedpreferences/sharedpreferences.dart'; import '../models/idea.dart'; import 'dart:convert'; class IdeaService { static const String ideasKey = 'localideas'; Future<void> saveIdea(Idea idea) async { final prefs = await SharedPreferences.getInstance(); final ideas = await loadAllIdeas(); final existingIndex = ideas.indexWhere((i) => i.id == idea.id); if (existingIndex >= 0) { ideas[existingIndex] = idea; } else { ideas.add(idea); } final jsonList = ideas.map((idea) => idea.toJson()).toList(); await prefs.setString(_ideasKey, jsonEncode(jsonList)); } Future<List<Idea>> loadAllIdeas() async { final prefs = await SharedPreferences.getInstance(); final jsonString = prefs.getString(_ideasKey) ?? '[]'; final jsonList = jsonDecode(jsonString) as List<dynamic>; return jsonList.map((json) => Idea.fromJson(json)).toList(); } Future<void> deleteIdea(int id) async { final prefs = await SharedPreferences.getInstance(); final ideas = await loadAllIdeas(); ideas.removeWhere((idea) => idea.id == id); final jsonList = ideas.map((idea) => idea.toJson()).toList(); await prefs.setString(_ideasKey, jsonEncode(jsonList)); } Future<void> markAsSynced(int id) async { final ideas = await loadAllIdeas(); final idea = ideas.firstWhere((i) => i.id == id, orElse: () => Idea(id: 0, title: "", content: "", timestamp: DateTime.now())); idea.synced = true; await saveIdea(idea); } } ` 2. 🔄 Синхронизация — sync_service.dart `dart // services/sync_service.dart import 'package:http/http.dart' as http; import 'dart:convert'; import '../models/idea.dart'; import 'idea_service.dart'; import 'api_service.dart'; class SyncService { final ApiService _api = ApiService(); final IdeaService _ideaService = IdeaService(); Future<void> syncIdeas() async { final ideas = await _ideaService.loadAllIdeas(); final unsynced = ideas.where((idea) => !idea.synced).toList(); for (var idea in unsynced) { final success = await _api.submitIdea(idea.title, idea.content); if (success) { await _ideaService.markAsSynced(idea.id); } } } Future<bool> isOnline() async { try { final result = await http.head(Uri.parse(ApiService.baseUrl)); return result.statusCode == 200; } catch (e) { return false; } } } ` 3. 🖼️ Список идей — ideaslistscreen.dart `dart // screens/ideaslistscreen.dart import 'package:flutter/material.dart'; import '../services/idea_service.dart'; import '../models/idea.dart'; import '../widgets/idea_card.dart'; class IdeasListScreen extends StatefulWidget { @override IdeasListScreenState createState() => IdeasListScreenState(); } class _IdeasListScreenState extends State<IdeasListScreen> { List<Idea> ideas = []; final IdeaService _ideaService = IdeaService(); @override void initState() { super.initState(); _loadIdeas(); } void _loadIdeas() async { final loaded = await _ideaService.loadAllIdeas(); setState(() => ideas = loaded); } void _deleteIdea(int id) async { await _ideaService.deleteIdea(id); _loadIdeas(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Te