Тест на вакансию

Первое приложение на Flutter

19 марта 2024 г.
181

Установка 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). Это достижение открывает новые возможности для пользователей получать самую свежую информацию обо всех значимых событиях и тенденциях в мире высоких технологий прямо на своих устройствах.
Поделиться: