Commit fd642dde authored by Wilko Manger's avatar Wilko Manger

Implement basic chat overview

parent c82b0239
Pipeline #306 failed
stages:
- build
- deploy
build:
stage: build
image: cirrusci/flutter:latest
tags:
- docker
artifacts:
name: pattle-$CI_COMMIT_SHORT_SHA
paths:
- pattle.apk
expire_in: 7 days
only:
refs:
- tags
variables:
- $STORE
script:
- 'echo "version: ${CI_COMMIT_REF_NAME:1}" >> pubspec.yaml'
- echo "$STORE" | base64 -d > app/pattle.keystore
- flutter build apk
- mv build/app/outputs/apk/release/app-release.apk pattle.apk
after_script:
- rm app/pattle.keystore
fdroid:
stage: deploy
tags:
- fdroid
only:
refs:
- tags
dependencies:
- build:debug-signed
script:
- find /mnt/fdroid -type f -name "*.apk" -mtime +5 -delete
- mv pattle.apk "/mnt/fdroid/repo/pattle-${CI_COMMIT_REF_NAME:1}.apk"
- docker run --rm -v /mnt/fdroid:/repo registry.gitlab.com/fdroid/docker-executable-fdroidserver:latest update
......@@ -45,11 +45,18 @@ android {
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
signingConfigs {
release {
keyAlias System.getenv('KEY_ALIAS')
keyPassword System.getenv('KEY_PASSWORD')
storeFile file('pattle.keystore')
storePassword System.getenv('STORE_PASSWORD')
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig signingConfigs.debug
signingConfig signingConfigs.release
}
}
}
......
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="im.pattle.app">
<uses-permission android:name="android.permission.INTERNET"/>
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
calls FlutterMain.startInitialization(this); in its onCreate method.
In most cases you can leave this as-is, but you if you want to provide
......
......@@ -17,7 +17,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:pattle/src/ui/main/main_page.dart';
import 'package:pattle/src/ui/chat/chat_overview_page.dart';
import 'package:pattle/src/ui/initial/initial_page.dart';
import 'package:pattle/src/ui/resources/localizations.dart';
import 'package:pattle/src/ui/resources/theme.dart';
import 'package:pattle/src/ui/start/advanced_page.dart';
......@@ -33,7 +34,6 @@ class App extends StatelessWidget {
onGenerateTitle: (BuildContext context)
=> l(context).appName,
theme: lightTheme,
localizationsDelegates: [
const AppLocalizationsDelegate(),
GlobalMaterialLocalizations.delegate,
......@@ -42,14 +42,14 @@ class App extends StatelessWidget {
supportedLocales: [
const Locale('en', 'US'),
],
initialRoute: '/start',
initialRoute: 'initial',
routes: {
'/': (context) => null,
'/main': (context) => MainPage(),
'/start': (context) => StartPage(),
'/start/advanced': (context) => AdvancedPage(),
'/start/username': (context) => UsernamePage(),
'/start/password': (context) => PasswordPage()
'initial': (context) => InitialPage(),
'chats': (context) => ChatOverviewPage(),
'start': (context) => StartPage(),
'start-advanced': (context) => AdvancedPage(),
'start-username': (context) => UsernamePage(),
'start-password': (context) => PasswordPage()
},
);
}
......
......@@ -16,6 +16,7 @@
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:matrix_sdk_sqflite/matrix_sdk_sqflite.dart';
import 'package:injector/injector.dart';
final inj = Injector();
......@@ -23,11 +24,24 @@ final inj = Injector();
Homeserver getHomeserver() => inj.getDependency<Homeserver>();
void registerHomeserver(Uri uri) {
inj.registerSingleton((_) => Homeserver(uri), override: true);
inj.registerSingleton<Homeserver>((_)
=> Homeserver(
uri
),
override: true);
}
Store getStore() => inj.getDependency<Store>();
void registerStore() {
final store = SqfliteStore(path: 'pattle.sqlite');
inj.registerSingleton<Store>((_)
=> store, override: true);
}
LocalUser getLocalUser() => inj.getDependency<LocalUser>();
void registerLocalUser(LocalUser user) {
inj.registerSingleton((_) => user, override: true);
inj.registerSingleton<LocalUser>((_) => user, override: true);
}
\ No newline at end of file
// Copyright (C) 2019 wilko
//
// This file is part of Pattle.
//
// Pattle is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pattle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:meta/meta.dart';
/// Chat overview used in the 'chats' page.
class ChatOverview {
final RoomId id;
String _name;
String get name => _name;
final RoomEvent latestEvent;
ChatOverview({@required this.id,
String name,
@required this.latestEvent}) {
_name = name ?? id.toString();
}
}
\ No newline at end of file
// Copyright (C) 2019 Wilko Manger
//
// This file is part of Pattle.
//
// Pattle is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pattle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/model/chat_overview.dart';
import 'package:rxdart/rxdart.dart';
import 'package:pattle/src/di.dart' as di;
final bloc = ChatOverviewBloc();
class ChatOverviewBloc {
PublishSubject<List<ChatOverview>> _chatsSubj = PublishSubject<List<ChatOverview>>();
Observable<List<ChatOverview>> get chats => _chatsSubj.stream;
Observable<bool> syncStream;
final LocalUser _user = di.getLocalUser();
Future<void> loadChats() async {
var chats = List<ChatOverview>();
// Get all rooms and push them as a single list
await for(Room room in _user.rooms.all()) {
var latestEvent = await room.events.all()
.lastWhere((event) => true, orElse: () => null);
var chat = ChatOverview(
id: room.id,
name: room.name,
latestEvent: latestEvent
);
chats.add(chat);
print(chats.length);
}
_chatsSubj.add(List.of(chats));
}
Future<void> startSync() async {
// Load from store before sync
loadChats();
Observable(_user.sync())
.listen((success) async {
if (success) {
await loadChats();
}
});
}
}
\ No newline at end of file
// Copyright (C) 2019 Wilko Manger
//
// This file is part of Pattle.
//
// Pattle is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Pattle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/model/chat_overview.dart';
import 'package:pattle/src/ui/chat/chat_overview_bloc.dart';
import 'package:pattle/src/ui/resources/localizations.dart';
class ChatOverviewPageState extends State<ChatOverviewPage> {
@override
void initState() {
super.initState();
bloc.startSync();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(l(context).appName)
),
body: Container(
child: Scrollbar(
child: buildOverviewList()
),
)
);
}
Widget buildOverviewList() {
return StreamBuilder<List<ChatOverview>>(
stream: bloc.chats,
builder: (BuildContext context, AsyncSnapshot<List<ChatOverview>> snapshot) {
switch(snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return Container();
case ConnectionState.active:
case ConnectionState.done:
var chats = snapshot.data;
return ListView.separated(
separatorBuilder: (context, index) => Divider(height: 1),
itemCount: chats.length,
itemBuilder: (context, index) {
var chat = chats[index];
return buildChatOverview(chat);
}
);
}
}
);
}
Widget buildChatOverview(ChatOverview chat) {
final event = chat.latestEvent;
var subtitle;
// Handle events
if (event is TextMessageEvent) {
subtitle = event.body ?? 'null';
} else {
subtitle = event?.sender.toString() ?? 'null';
}
// TODO: Better time formatting
var t = chat.latestEvent?.time;
var time;
if (t != null) {
var hour = t.hour.toString().padLeft(2, '0');
var minute = t.minute.toString().padLeft(2, '0');
time = '$hour:$minute';
} else {
time = '';
}
return ListTile(
title: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
Expanded(
child: Text(chat.name),
),
Text(
time,
style: Theme.of(context).textTheme.subtitle
.copyWith(fontWeight: FontWeight.normal)
)
]
),
dense: false,
onTap: () { },
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
subtitle: Text(subtitle,
overflow: TextOverflow.ellipsis,
),
);
}
}
class ChatOverviewPage extends StatefulWidget {
@override
State<StatefulWidget> createState() => ChatOverviewPageState();
}
\ No newline at end of file
......@@ -16,31 +16,28 @@
// along with Pattle. If not, see <https://www.gnu.org/licenses/>.
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/model/chat_overview.dart';
import 'package:rxdart/rxdart.dart';
import 'package:pattle/src/di.dart' as di;
final main = MainBloc();
class MainBloc {
PublishSubject<List<Room>> _roomsSubj = PublishSubject<List<Room>>();
Observable<List<Room>> get rooms => _roomsSubj.stream;
Observable<bool> syncStream;
void startSync() {
Observable(di.getLocalUser().sync())
.listen((success) async {
print('sync: $success');
// Get all rooms and push them as a single list
var rooms = List<Room>();
if (success) {
await for(Room room in di.getLocalUser().rooms.all()) {
rooms.add(room);
}
}
print('adding these rooms: ${rooms.length}');
_roomsSubj.add(rooms);
});
final bloc = InitialBloc();
class InitialBloc {
final _loggedInSubj = BehaviorSubject<bool>();
Observable<bool> get loggedIn
=> _loggedInSubj.stream;
void checkIfLoggedIn() async {
di.registerStore();
var localUser = await LocalUser.fromStore(di.getStore());
print(localUser);
if (localUser != null) {
di.registerLocalUser(localUser);
}
_loggedInSubj.add(localUser != null);
}
}
\ No newline at end of file
......@@ -17,64 +17,46 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:matrix_sdk/matrix_sdk.dart';
import 'package:pattle/src/ui/main/main_bloc.dart';
import 'package:pattle/src/ui/resources/localizations.dart';
import 'package:pattle/src/ui/initial/initial_bloc.dart';
class MainPageState extends State<MainPage> {
class InitialPageState extends State<InitialPage> {
StreamSubscription<bool> subscription;
@override
void initState() {
super.initState();
main.startSync();
subscription = bloc.loggedIn.listen((loggedIn) {
var route;
if (loggedIn) {
route = 'chats';
} else {
route = 'start';
}
Navigator.pushNamedAndRemoveUntil(context, route, (route) => false);
});
bloc.checkIfLoggedIn();
}
@override
void dispose() {
super.dispose();
subscription.cancel();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(l(context).appName)
),
body: Container(
child: Scrollbar(
child: StreamBuilder<List<Room>>(
stream: main.rooms,
builder: (BuildContext context, AsyncSnapshot<List<Room>> snapshot) {
print('connectionState: ${snapshot.connectionState}');
switch(snapshot.connectionState) {
case ConnectionState.none:
case ConnectionState.waiting:
return Container();
case ConnectionState.active:
case ConnectionState.done:
var rooms = snapshot.data;
print('LENGTH: ${rooms.length}');
return ListView.builder(
itemCount: rooms.length,
itemBuilder: (context, index) {
Room room = rooms[index];
return ListTile(
title: Text(room.name ?? room.id.toString()),
);
}
);
}
}
)
),
)
);
return Container();
}
}
class MainPage extends StatefulWidget {
class InitialPage extends StatefulWidget {
@override
State<StatefulWidget> createState() => MainPageState();
State<StatefulWidget> createState() => InitialPageState();
}
\ No newline at end of file
......@@ -29,91 +29,91 @@ class AdvancedPageState extends State<AdvancedPage> {
@override
void initState() {
homeserverTextController.text = start.homeserver.uri.toString();
homeserverTextController.text = bloc.homeserver.uri.toString();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
l(context).advanced,
style: TextStyle(color: Theme.of(context).primaryColor)
),
iconTheme: IconThemeData(color: Theme.of(context).primaryColor),
elevation: 0,
backgroundColor: const Color(0x00000000),
appBar: AppBar(
title: Text(
l(context).advanced,
style: TextStyle(color: Theme.of(context).primaryColor)
),
body: Container(
margin: EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
iconTheme: IconThemeData(color: Theme.of(context).primaryColor),
elevation: 0,
backgroundColor: const Color(0x00000000),
),
body: Container(
margin: EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.home,
color: Theme.of(context).hintColor,
size: 32
),
SizedBox(width: 16),
Flexible(
child: StreamBuilder<bool>(
stream: start.homeserverChanged,
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
Icon(Icons.home,
color: Theme.of(context).hintColor,
size: 32
),
SizedBox(width: 16),
Flexible(
child: StreamBuilder<bool>(
stream: bloc.homeserverChanged,
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
var errorText;
var errorText;
if (snapshot.hasError) {
errorText = l(context).hostnameInvalidError;
} else {
errorText = null;
}
if (snapshot.hasError) {
errorText = l(context).hostnameInvalidError;
} else {
errorText = null;
}
return TextField(
controller: homeserverTextController,
decoration: InputDecoration(
filled: true,
labelText: l(context).homeserver,
hintText: start.homeserver.uri.toString(),
errorText: errorText
),
);
}
)
return TextField(
controller: homeserverTextController,
decoration: InputDecoration(
filled: true,
labelText: l(context).homeserver,
hintText: bloc.homeserver.uri.toString(),
errorText: errorText
),
);
}
)
],
)
],
),
SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.person,
color: Theme.of(context).hintColor,
size: 32
),
SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.person,
color: Theme.of(context).hintColor,
size: 32
SizedBox(width: 16),
Flexible(
child: TextField(
decoration: InputDecoration(
filled: true,
labelText: l(context).identityServer
),
SizedBox(width: 16),
Flexible(
child: TextField(
decoration: InputDecoration(
filled: true,
labelText: l(context).identityServer
),
),
)
],
),
SizedBox(height: 32),
RaisedButton(
onPressed: () {
start.setHomeserverUri(homeserverTextController.text);
Navigator.pop(context);
},
child: Text(l(context).confirm.toUpperCase())
),
)
],
),
SizedBox(height: 32),
RaisedButton(
onPressed: () {
bloc.setHomeserverUri(homeserverTextController.text);
Navigator.pop(context);
},
child: Text(l(context).confirm.toUpperCase())
)
],
)
)
);
}
}
\ No newline at end of file
......@@ -36,10 +36,10 @@ class UsernamePageState extends State<UsernamePage> {
void initState() {
super.initState();
subscription = start.isUsernameAvailable.listen((state) {
subscription = bloc.isUsernameAvailable.listen((state) {
if (state == UsernameAvailableState.available
|| state == UsernameAvailableState.unavailable) {
Navigator.pushNamed(context, "/start/password");
Navigator.pushNamed(context, "start-password");
}
});
}
......@@ -52,7 +52,7 @@ class UsernamePageState extends State<UsernamePage> {
}
void _next(BuildContext context) {
start.checkUsernameAvailability(usernameController.text);
bloc.checkUsernameAvailability(usernameController.text);
}
@override
......@@ -68,7 +68,7 @@ class UsernamePageState extends State<UsernamePage> {
margin: EdgeInsets.only(top: 32, right: 16),
child: FlatButton(
onPressed: () {
Navigator.pushNamed(context, '/start/advanced');
Navigator.pushNamed(context, 'start-advanced');
},
child: Text(
l(context).advanced.toUpperCase()
......@@ -88,7 +88,7 @@ class UsernamePageState extends State<UsernamePage> {
),
SizedBox(height: 16),
StreamBuilder<UsernameAvailableState>(
stream: start.isUsernameAvailable,
stream: bloc.isUsernameAvailable,
builder: (BuildContext context, AsyncSnapshot<UsernameAvailableState> snapshot) {
String errorText;
......@@ -126,7 +126,7 @@ class UsernamePageState extends State<UsernamePage> {
),
SizedBox(height: 16),
StreamBuilder<UsernameAvailableState>(
stream: start.isUsernameAvailable,
stream: bloc.isUsernameAvailable,
builder: (BuildContext context, AsyncSnapshot<UsernameAvailableState> snapshot) {
final enabled = snapshot.data != UsernameAvailableState.checking;
var onPressed;
......