19 марта 2024 г.
Установка Flutter SDK
Загружаем и устанавливаем Flutter в зависимости от ОС:
Если используем VS Code, то полезное разширение
— https://marketplace.visualstudio.com/items?itemName=Dart-Code.flutter.
Если разработка под Android, то загружаем и устанавливаем Android Studio.
Создаём новое приложение:
flutter create itnewsapp
cd itnewsapp
Flutter создаст структуру проекта, ключевые из них:
- lib/main.dart - это главный файл, с которым мы будем работать. Именно здесь пишется код вашего приложения.
- pubspec.yaml - файл конфигурации проекта. Здесь вы указываете зависимости (сторонние библиотеки), assets (картинки, шрифты) и свойства проекта.
- android/, ios/, windows/, linux - нативные папки для каждой платформы.
Чтобы посмотреть список доступных устройств на которых можно запустить приложение:
flutter devices
Для доступных эмуляторов:
flutter emulators
Настраиваем файл конфигурации
Редактируем файл pubspec.yaml:
name: itnewsapp
description: "IT News Flutter project."
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.9.0
dependencies:
flutter:
sdk: flutter
http: ^1.1.0
cached_network_image: ^3.3.0
intl: ^0.18.1
flutter_svg: ^2.0.7
flutter_launcher_icons: "^0.13.1"
cupertino_icons: ^1.0.8
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter_icons:
android: true
ios: true
image_path: "assets/icons/icon.png"
adaptive_icon_foreground: "assets/icons/icon_foreground.png"
adaptive_icon_background: "assets/icons/icon_background.png"
flutter:
assets:
- assets/images/
uses-material-design: true
Выполняем команду:
flutter pub get
Меняем главный файл приложения
Отредактируем (lib/main.dart):
import 'package:flutter/material.dart';
import 'screens/news_list_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Новостное приложение IT News',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const NewsListScreen(),
debugShowCheckedModeBanner: false,
);
}
}
Создаём модель данных новостей
Для этого создадим файл (lib/models/news_model.dart) и напишем:
class News {
final int id;
final String title;
final String content;
final String? imageUrl;
final DateTime date;
News({
required this.id,
required this.title,
required this.content,
this.imageUrl,
required this.date,
});
factory News.fromJson(Map<String, dynamic> json) {
return News(
id: json['id'] ?? 0,
title: json['title'] ?? 'Без названия',
content: json['content'] ?? '',
imageUrl: json['image'],
date: DateTime.parse(json['date'] ?? DateTime.now().toString()),
);
}
}
Создаем сервис для работы с API
Для этого создадим файл (lib/services/news_service.dart) и напишем:
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/news_model.dart';
import '../utils/network_utils.dart';
class NewsService {
static const String _baseUrl = 'https://www.jas.ru/api/it_news_app/';
static const Duration _timeoutDuration = Duration(seconds: 10);
static const Map<String, String> _headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Accept-Charset': 'utf-8',
'User-Agent': 'ITNewsApp/1.0',
};
static Future<List<News>> fetchNews() async {
final hasConnection = await NetworkUtils.hasNetworkConnection();
if (!hasConnection) {
throw Exception('Нет подключения к интернету');
}
try {
final response = await http
.get(
Uri.parse(_baseUrl),
headers: _headers,
)
.timeout(_timeoutDuration);
if (response.statusCode == 200) {
final List<dynamic> jsonResponse = json.decode(response.body);
return jsonResponse.map((json) => News.fromJson(json)).toList();
} else {
throw Exception('Ошибка сервера: ${response.statusCode}');
}
} on http.ClientException {
throw Exception('Ошибка подключения к серверу');
} on TimeoutException {
throw Exception('Превышено время ожидания');
} on FormatException {
throw Exception('Ошибка формата данных');
} catch (e) {
throw Exception('Неизвестная ошибка: $e');
}
}
}
Создаём утилиту для проверки доступа в интернет
Для этого создадим файл (lib/utils/network_utils.dart) и напишем:
import 'dart:io';
class NetworkUtils {
static Future<bool> hasNetworkConnection() async {
try {
final result = await InternetAddress.lookup('www.jas.ru');
return result.isNotEmpty && result[0].rawAddress.isNotEmpty;
} on SocketException catch (_) {
return false;
}
}
}
Создаем экраны
Сделаем экран вывода списка новостей, для этого создадим файл (lib/screens/news_list_screen.dart) и напишем:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../models/news_model.dart';
import '../services/news_service.dart';
import 'news_detail_screen.dart';
class NewsListScreen extends StatefulWidget {
const NewsListScreen({super.key});
@override
State<NewsListScreen> createState() => _NewsListScreenState();
}
class _NewsListScreenState extends State<NewsListScreen> {
late Future<List<News>> _futureNews;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_futureNews = NewsService.fetchNews();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color.fromRGBO(45, 45, 45, 0.9),
appBar: AppBar(
backgroundColor: const Color.fromRGBO(45, 45, 45, 1.0),
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SvgPicture.asset(
'assets/images/logo.svg',
width: 100,
height: 24,
fit: BoxFit.scaleDown,
),
const SizedBox(width: 8),
],
),
),
body: FutureBuilder<List<News>>(
future: _futureNews,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(
color: Color.fromRGBO(114, 24, 67, 1.0),
));
} else if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(
'Ошибка: ${snapshot.error}',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
setState(() {
_futureNews = NewsService.fetchNews();
});
},
child: const Text('Попробовать снова'),
),
],
),
);
} else if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return _buildNewsList(snapshot.data!);
} else {
return const Center(
child: Text('Новости не найдены'),
);
}
},
),
);
}
Widget _buildNewsList(List<News> newsList) {
return RefreshIndicator(
onRefresh: () async {
setState(() {
_futureNews = NewsService.fetchNews();
});
},
child: ListView.builder(
controller: _scrollController,
itemCount: newsList.length,
itemBuilder: (context, index) {
final news = newsList[index];
return _buildNewsItem(news, context);
},
),
);
}
Widget _buildNewsItem(News news, BuildContext context) {
return Card(
margin: const EdgeInsets.all(8),
color: const Color.fromRGBO(45, 45, 45, 0.8),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(0),
),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NewsDetailScreen(news: news),
),
);
},
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (news.imageUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(0),
child: Image.network(
news.imageUrl!,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
height: 200,
width: double.infinity,
color: Colors.grey[300],
child: const Icon(Icons.error),
),
),
),
const SizedBox(height: 12),
Text(
news.title,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
DateFormat('dd.MM.yyyy HH:mm').format(news.date),
style: TextStyle(
fontSize: 14,
color: Color.fromRGBO(114, 24, 67, 1.0),
),
),
],
),
const SizedBox(height: 8),
Text(
news.content.length > 100
? '${news.content.substring(0, 100)}...'
: news.content,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
}
Также, сделаем экран просмотра новости, для этого создадим файл (lib/screens/news_detail_screen.dart) и напишем:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:flutter_svg/flutter_svg.dart';
import '../models/news_model.dart';
class NewsDetailScreen extends StatelessWidget {
final News news;
const NewsDetailScreen({super.key, required this.news});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color.fromRGBO(45, 45, 45, 0.9),
appBar: AppBar(
backgroundColor: const Color.fromRGBO(45, 45, 45, 1.0),
iconTheme: const IconThemeData(
color: Colors.white,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SvgPicture.asset(
'assets/images/logo.svg',
width: 100,
height: 24,
fit: BoxFit.scaleDown,
),
const SizedBox(width: 8),
],
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (news.imageUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(0),
child: Image.network(
news.imageUrl!,
width: double.infinity,
height: 250,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
height: 250,
color: Colors.grey[300],
child: const Center(
child: Icon(Icons.error, size: 64),
),
),
),
),
const SizedBox(height: 20),
Text(
news.title,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 16),
Row(
children: [
Text(
DateFormat('dd.MM.yyyy HH:mm').format(news.date),
style: TextStyle(
fontSize: 14,
color: Color.fromRGBO(114, 24, 67, 1.0),
),
),
],
),
const SizedBox(height: 20),
Text(
news.content,
style: const TextStyle(
fontSize: 16,
height: 1.6,
color: Colors.white,
),
textAlign: TextAlign.justify,
),
],
),
),
);
}
}
AndroidManifest
Для того чтобы приложение на телефоне видело интернет, добавьте разрешение в android/app/src/main/AndroidManifest.xml перед <application></application>:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
Картинки
Создаём папки:
- assets/icons
- assets/images
Качаем
отсюда картинки и ложим в папку icons. В папке images создаём файл logo.svg и пишем:
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="254.6px" height="46px" viewBox="0 0 254.6 46" style="enable-background:new 0 0 254.6 46;" xml:space="preserve">
<polyline fill="#721843" points="0,45 0,0.5 11.1,0.5 11.1,45 0,45 "/>
<polyline fill="#721843" points="22.3,0.5 55.6,0.5 55.6,11.6 44.5,11.6 44.5,45 33.4,45 33.4,11.6 22.3,11.6 22.3,0.5 "/>
<polygon fill="#FFFFFF" points="64.6,0 64.6,45 73.6,45 73.6,18 91.6,45 100.6,45 100.6,0 91.6,0 91.6,27 73.6,0 "/>
<polygon fill="#FFFFFF" points="110.6,9 110.6,46 146.6,46 146.6,37 119.6,37 119.6,27 137.6,27 137.6,18 119.6,18 119.6,9 146.6,9 146.6,0 110.6,0 "/>
<polyline fill="#FFFFFF" points="154.6,1 168.6,45 172.6,45 181.6,28 190.6,45 193.6,45 208.6,1 199.6,1 190.6,24 181.6,10 172.6,24 163.6,1 154.6,1 "/>
<polygon fill="#FFFFFF" points="218.6,0 254.6,0 254.6,9 227.6,9 227.6,18 254.6,18 254.6,46 218.6,46 218.6,37 245.6,37 245.6,27 218.6,27 "/>
</svg>
Генерируем иконки:
flutter pub run flutter_launcher_icons
Тестирование приложения
Для запуска приложения:
flutter run
Для запуска эмулятора, если он не запущен:
flutter emulators --launch <emulator id>
Для запуска приложения на конкретном эмуляторе:
flutter run -d <emulator id>
После запуска приложения доступны следующие горячие клавиши:
- r - горячая перезагрузка.
- R - горячий перезапуск.
- h - список всех доступных интерактивных команд.
- d - отключить (завершить «flutter run», но оставить приложение запущенным).
- c - очистить экран.
- q - выход (завершить приложение на устройстве).
Сборка приложения
flutter build <subcommand>
Например, следующие команды:
- aar — создает репозиторий, содержащий файл AAR (Android Archive) и POM (Project Object Model) файл. Используется для интеграции Flutter-библиотек в другие Android-проекты.
- apk — собирает файл APK (Android Application Package), который можно установить на Android-устройства.
- appbundle — создает файл Android App Bundle (.aab), который используется для публикации приложений в Google Play и позволяет оптимизировать размер скачивания.
- bundle — создает директорию с ресурсами и ассетами Flutter-приложения, необходимую для сборки.
- web — собирает веб-приложение Flutter в веб-совместимый пакет.
- windows — создает десктопное приложение для Windows.
Таким образом, мы успешно разработали наше первое приложение, предназначенное специально для отображения актуальной новостной ленты в сфере информационных технологий (IT News). Это достижение открывает новые возможности для пользователей получать самую свежую информацию обо всех значимых событиях и тенденциях в мире высоких технологий прямо на своих устройствах.